├── .gitattributes
├── .gitignore
├── .idea
└── vcs.xml
├── License.txt
├── README.md
├── __init__.py
├── examples
├── archive_site.py
├── basic_methods.py
├── create_site_sample.py
├── hyper_api_samples.py
├── limited_rest_api_wrapping_tableau_rest_api.py
├── modifying_users_immediately_before_sso.py
├── move_extracts_from_server_to_server.py
├── permissions_auditing.py
├── replicate_site_structure_sample.py
├── template_publish_sample.py
├── test_suite_all_add_update_delete_publish_tableau_server_rest.py
├── test_suite_all_querying_tableau_server_rest.py
├── test_suite_tableau_documents.py
└── user_sync_sample.py
├── logger.py
├── logging_methods.py
├── requirements.txt
├── rest_tokens_manager.py
├── setup.py
├── tabcmd.py
├── tableau_documents
├── __init__.py
├── hyper_file_generator.py
├── table_relations.py
├── tableau_columns.py
├── tableau_connection.py
├── tableau_datasource.py
├── tableau_document.py
├── tableau_file.py
├── tableau_parameters.py
└── tableau_workbook.py
├── tableau_emailer.py
├── tableau_exceptions.py
├── tableau_http.py
├── tableau_repository.py
├── tableau_rest_api
├── __init__.py
├── methods
│ ├── __init__.py
│ ├── alert.py
│ ├── datasource.py
│ ├── extract.py
│ ├── favorites.py
│ ├── flow.py
│ ├── group.py
│ ├── metadata.py
│ ├── metrics.py
│ ├── project.py
│ ├── rest_api_base.py
│ ├── revision.py
│ ├── schedule.py
│ ├── site.py
│ ├── subscription.py
│ ├── user.py
│ ├── webhooks.py
│ └── workbook.py
├── permissions.py
├── published_content.py
├── rest_json_request.py
├── rest_xml_request.py
├── sort.py
└── url_filter.py
├── tableau_rest_xml.py
└── tableau_server_rest.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
4 | # Custom for Visual Studio
5 | *.cs diff=csharp
6 |
7 | # Standard to msysgit
8 | *.doc diff=astextplain
9 | *.DOC diff=astextplain
10 | *.docx diff=astextplain
11 | *.DOCX diff=astextplain
12 | *.dot diff=astextplain
13 | *.DOT diff=astextplain
14 | *.pdf diff=astextplain
15 | *.PDF diff=astextplain
16 | *.rtf diff=astextplain
17 | *.RTF diff=astextplain
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Windows image file caches
2 | Thumbs.db
3 | ehthumbs.db
4 |
5 | # Folder config file
6 | Desktop.ini
7 |
8 | # Recycle Bin used on file shares
9 | $RECYCLE.BIN/
10 |
11 | # Windows Installer files
12 | *.cab
13 | *.msi
14 | *.msm
15 | *.msp
16 |
17 | # Windows shortcuts
18 | *.lnk
19 |
20 | # =========================
21 | # Operating System Files
22 | # =========================
23 |
24 | # OSX
25 | # =========================
26 |
27 | .DS_Store
28 | .AppleDouble
29 | .LSOverride
30 |
31 | # Thumbnails
32 | ._*
33 |
34 | # Files that might appear in the root of a volume
35 | .DocumentRevisions-V100
36 | .fseventsd
37 | .Spotlight-V100
38 | .TemporaryItems
39 | .Trashes
40 | .VolumeIcon.icns
41 |
42 | # Directories potentially created on remote AFP share
43 | .AppleDB
44 | .AppleDesktop
45 | Network Trash Folder
46 | Temporary Items
47 | .apdisk
48 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/License.txt:
--------------------------------------------------------------------------------
1 | tableau_tools
2 | Copyright (c) 2017 Tableau (United States)
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
1 | __author__ = 'bhowell'
2 | #from .tableau_base import TableauBase
3 | from .logger import Logger
4 | from .tableau_exceptions import *
5 | #from tableau_rest_api import *
6 | #from tableau_documents import *
7 | # from .tableau_rest_api_connection import TableauRestApiConnection
8 | #from .tableau._server_rest import TableauServerRest, TableauServerRest33
9 | from .tableau_server_rest import *
10 | from .rest_tokens_manager import *
11 |
--------------------------------------------------------------------------------
/examples/archive_site.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | # from tableau_tools.tableau_rest_api import *
4 | from tableau_tools import *
5 | import os
6 |
7 |
8 | def archive_tableau_site(save_to_directory, server, username, password, site_content_url):
9 | # The last two digits of this constructor match to the version of API available on the Tableau Server
10 | t = TableauServerRest33(server=server, username=username,
11 | password=password, site_content_url=site_content_url)
12 | t.signin()
13 | all_projects = t.projects.query_projects()
14 | all_projects_dict = t.xml_list_to_dict(all_projects)
15 |
16 | # This gives you the Project name; the values of the dict are the LUIDs
17 | for project in all_projects_dict:
18 | # Create directory for projects
19 | try:
20 | print('Making directory {}'.format(project))
21 | os.mkdir('{}/{}'.format(save_to_directory, project))
22 | except OSError as e:
23 | print('Directory already exists')
24 |
25 | print('Downloading datasources for project {}'.format(project))
26 | # Get All Data sources
27 | dses_in_project = t.datasources.query_datasources(project_name_or_luid=all_projects_dict[project])
28 | for ds in dses_in_project:
29 | ds_luid = ds.get('id')
30 | ds_content_url = ds.get('contentUrl')
31 | print('Downloading datasource {}'.format(ds_content_url))
32 | t.datasources.download_datasource(ds_name_or_luid=ds_luid,
33 | filename_no_extension="{}/{}/{}".format(save_to_directory, project, ds_content_url),
34 | include_extract=False)
35 |
36 | print('Downloading workbooks for project {}'.format(project))
37 | wbs_in_project = t.workbooks.query_workbooks_in_project(project_name_or_luid=all_projects_dict[project])
38 | for wb in wbs_in_project:
39 | wb_luid = wb.get('id')
40 | wb_content_url = wb.get('contentUrl')
41 | print(('Downloading workbook {}'.format(wb_content_url)))
42 | t.workbooks.download_workbook(wb_name_or_luid=wb_luid,
43 | filename_no_extension="{}/{}/{}".format(save_to_directory, project, wb_content_url),
44 | include_extract=False)
--------------------------------------------------------------------------------
/examples/basic_methods.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from tableau_tools import *
4 |
5 | # This example shows how to build up new commands from the basic components, to implement new features or arguments or
6 | # whatever in the future
7 |
8 | server = 'http://127.0.0.1'
9 | username = ''
10 | password = ''
11 |
12 | # Using api_version overrides the default internal API version that would be used by the class. It would have been 3.6, b
13 | # but the optional argument is forcing the commands to be sent with 3.11 instead of 3.6 in the API URLs
14 | d = TableauServerRest36(server=server, username=username, password=password, site_content_url='default', api_version='3.11')
15 | d.signin()
16 |
17 | # Maybe "Get Recently Viewed for Site" hasn't been implemented yet or was forgotten
18 | # https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_site.htm#get_recently_viewed
19 | # Since it is a get command, we can use query_resource
20 |
21 | recently_viewed = d.query_resource("content/recent")
22 | data_acceleration_report = d.query_resource('dataAccelerationReport')
23 |
24 | # What if there were additional properties to a Site update that the current update_site() doesn't handle
25 |
26 | # server_level=True excludes the "site/site-id/" portion that would typically be automatically added
27 | url = d.build_api_url("", server_level=True)
28 |
29 | # build the XML request
30 | # All requests start with a 'tsRequest' outer element
31 | tsr = ET.Element('tsRequest')
32 | s = ET.Element('site')
33 | s.set('name', 'Fancy New Site Name')
34 | s.set('state', 'Disabled')
35 | s.set('catalogObfuscationEnabled','true')
36 |
37 | # Append all your inner elements into the outer tsr element
38 | tsr.append(s)
39 |
40 | response = d.send_update_request(url=url,request=tsr)
41 |
42 |
--------------------------------------------------------------------------------
/examples/create_site_sample.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from tableau_tools import *
4 | import time
5 |
6 | server = 'http://127.0.0.1'
7 | username = ''
8 | password = ''
9 |
10 | new_site_content_url = 'test_site'
11 | new_site_name = 'Sample Test Site'
12 |
13 | # Choose the API version for your server. 2019.3 = TableauRestApiConnection35
14 | default = TableauServerRest(server=server, username=username, password=password, site_content_url='default')
15 | try:
16 | default.signin()
17 | default.sites.create_site(new_site_name=new_site_name, new_content_url=new_site_content_url)
18 | except AlreadyExistsException as e:
19 | print(e.msg)
20 | print("Cannot create new site, it already exists")
21 | exit()
22 |
23 | time.sleep(4)
24 |
25 | t = TableauServerRest(server=server, username=username, password=password,
26 | site_content_url=new_site_content_url)
27 | t.signin()
28 | logger = Logger('create_site_sample.log')
29 | # Enable logging after sign-in to hide credentials
30 | t.enable_logging(logger)
31 |
32 | # Create some groups
33 | groups_to_create = ['Administrators', 'Executives', 'Managers', 'Line Level Workers']
34 |
35 | for group in groups_to_create:
36 | t.groups.create_group(group_name=group)
37 |
38 | # Remove all permissions from Default Project
39 | time.sleep(4)
40 | default_proj = t.projects.query_project(project_name_or_luid='Default')
41 | default_proj.lock_permissions()
42 |
43 | default_proj.clear_all_permissions() # This clears all, including the defaults
44 |
45 | # Add in any default permissions you'd like at this point
46 | admin_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators',
47 | role='Project Leader')
48 | default_proj.set_permissions_by_permissions_obj_list([admin_perms, ])
49 |
50 | admin_perms = default_proj.workbook_defaults.get_permissions_obj(group_name_or_luid='Administrators',
51 | role='Editor')
52 | admin_perms.set_capability(capability_name='Download Full Data', mode='Deny')
53 | default_proj.workbook_defaults.set_permissions_by_permissions_obj_list([admin_perms, ])
54 |
55 | admin_perms = default_proj.datasource_defaults.get_permissions_obj(group_name_or_luid='Administrators',
56 | role='Editor')
57 | default_proj.datasource_defaults.set_permissions_by_permissions_obj_list([admin_perms, ])
58 |
59 | # Change one of these
60 | new_perms = default_proj.get_permissions_obj(group_name_or_luid='Administrators',
61 | role='Publisher')
62 | default_proj.set_permissions_by_permissions_obj_list([new_perms, ])
63 |
64 | # Create Additional Projects
65 | projects_to_create = ['Sandbox', 'Data Source Definitions', 'UAT', 'Finance', 'Real Financials']
66 | for project in projects_to_create:
67 | t.projects.create_project(project_name=project, no_return=True)
68 |
69 | # Set any additional permissions on each project
70 |
71 | # Add Users
72 | users_to_add = ['user_1', 'user_2', 'user_3']
73 | for user in users_to_add:
74 | t.users.add_user(username=user, fullname=user, site_role='Publisher')
75 |
76 | time.sleep(3)
77 | # Add Users to Groups
78 | t.groups.add_users_to_group(username_or_luid_s='user_1', group_name_or_luid='Managers')
79 | t.groups.add_users_to_group(username_or_luid_s='user_2', group_name_or_luid='Administrators')
80 | t.groups.add_users_to_group(username_or_luid_s=['user_2', 'user_3'], group_name_or_luid='Executives')
--------------------------------------------------------------------------------
/examples/hyper_api_samples.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from tableau_tools import *
3 | from tableau_tools.tableau_documents import *
4 | from tableau_tools.tableau_documents.hyper_file_generator import HyperFileGenerator
5 |
6 | import pyodbc
7 | import sys
8 |
9 | # Extract files (.hyper and .tde) have very little metadata in them
10 | # They are most useful when combined with XML in a TDS file, wrapped together as a TDSX
11 | # or with that same XML in a TWB file, packaged together as a TWBX
12 |
13 | # Two ways to handle the process of attaching the data source XML to the updated Extract file:
14 | # 1. Substitution: Create a valid TDSX/TWBX in Tableau Desktop, then use that file as a base for substituting updated
15 | # Extract files. As long as structure of Extract does not change, should work all the time
16 | # 2. Fom Scratch: If the structure of the Extract can vary, then you cannot pre-build the XML.
17 | # tableau_tools has the necessary methods to build the data source XML from scratch, including relationships
18 |
19 |
20 | #
21 | # Getting Data Into an Extract / Building the Extract
22 | #
23 |
24 | # One way to create the Extract is to pass a pyoodbc cursor
25 | # This function handles some of the encoding parameters correctly for you since Extracts are unicode
26 | def pyodbc_connect_and_query(odbc_connect_string: str, query: str) -> pyodbc.Cursor:
27 |
28 | try:
29 | conn = pyodbc.connect(odbc_connect_string)
30 | conn.setdecoding(pyodbc.SQL_WCHAR, encoding='utf-8')
31 | conn.setencoding(str, encoding='utf-8')
32 | conn.setencoding(str, encoding='utf-8', ctype=pyodbc.SQL_CHAR)
33 |
34 | # https://github.com/mkleehammer/pyodbc/issues/194 for this encoding fix
35 |
36 | conn.setdecoding(pyodbc.SQL_WMETADATA, encoding='utf-32le')
37 | except pyodbc.Error as e:
38 | print("ODBC Connection Error Message:\n")
39 | print(e)
40 | print("ODBC error, exiting...\n")
41 | sys.exit()
42 | cursor = conn.cursor()
43 |
44 | try:
45 | cursor.execute(query)
46 | except pyodbc.ProgrammingError as e:
47 | print("\nODBC Query Error Message:\n")
48 | print(e)
49 | print("\nODBC error, exiting...\n")
50 | sys.exit()
51 | return cursor
52 |
53 | # Example of creating a single Hyper file
54 | def hyper_create_one_table_from_pyodbc_cursor(new_hyper_filename: str, table_1_pyodbc_conn_string: str,
55 | table_1_query: str, table_1_tableau_name: str):
56 |
57 | # HyperFileGenerator is a wrapper class for the Extract API 2.0
58 | # Use Hyper API (3.0) instead if you want the latest and greatest
59 | h = HyperFileGenerator()
60 |
61 | # Table 1
62 | filled_cursor = pyodbc_connect_and_query(table_1_pyodbc_conn_string, table_1_query)
63 |
64 | # This method gets all of the info from the table schema and correctly creates the extract schema
65 | h.create_table_definition_from_pyodbc_cursor(filled_cursor)
66 | # This takes the cursor, reads through all the rows, and ads them into the extract
67 | h.create_extract(new_hyper_filename, append=True, table_name=table_1_tableau_name, pyodbc_cursor=filled_cursor)
68 |
69 | print('Table 1 added to file {}'.format(new_hyper_filename))
70 |
71 | # Example of creating two tables in a single Hyper file
72 | def hyper_create_two_tables(new_hyper_filename: str, table_1_pyodbc_conn_string: str, table_1_query: str,
73 | table_1_tableau_name: str,
74 | table_2_pyodbc_conn_string: str, table_2_query: str, table_2_tableau_name: str):
75 |
76 | # HyperFileGenerator is a wrapper class for the Extract API
77 | h = HyperFileGenerator()
78 |
79 | # Table 1
80 | filled_cursor = pyodbc_connect_and_query(table_1_pyodbc_conn_string, table_1_query)
81 |
82 | # This method gets all of the info from the table schema and correctly creates the extract schema
83 | h.create_table_definition_from_pyodbc_cursor(filled_cursor)
84 | # This takes the cursor, reads through all the rows, and ads them into the extract
85 | h.create_extract(new_hyper_filename, append=True, table_name=table_1_tableau_name, pyodbc_cursor=filled_cursor)
86 |
87 | print('Table 1 added to file {}'.format(new_hyper_filename))
88 |
89 | # Table 2
90 | filled_cursor_2 = pyodbc_connect_and_query(table_2_pyodbc_conn_string, table_2_query)
91 | # This method gets all of the info from the table schema and correctly creates the extract schema
92 | h.create_table_definition_from_pyodbc_cursor(filled_cursor_2)
93 | # This takes the cursor, reads through all the rows, and ads them into the extract
94 | h.create_extract(new_hyper_filename, append=True, table_name=table_2_tableau_name, pyodbc_cursor=filled_cursor_2)
95 | print('Table 2 added to file {}'.format(new_hyper_filename))
96 |
97 | print('All Done with Hyper create')
98 | return True
99 |
100 | #
101 | # Substitution
102 | #
103 |
104 |
105 | # Specify the existing Extract file you want replaced
106 | # It should match exactly, so it's best to do this after building the TDSX/TWBX the first time in Desktop using
107 | # an Extract file you created programmatically using the same code as the one you will substitute in here
108 | def substitute_an_existing_extract(tableau_packaged_filename: str, new_extract_filename: str,
109 | previous_extract_flename_in_packaged_file: Optional[str] = None) -> str:
110 | t_file = TableauFileManager.open(filename=tableau_packaged_filename)
111 | # See what files might be in there if you need
112 | files_in_there = t_file.get_filenames_in_package()
113 |
114 | if previous_extract_flename_in_packaged_file is not None:
115 | existing_hyper_filename = previous_extract_flename_in_packaged_file
116 | else:
117 | existing_hyper_filename = None
118 | # This is simple test on expectation of single Hyper file but you could probably do more
119 | for file in files_in_there:
120 | if file.lower().find('.hyper') != -1:
121 | existing_hyper_filename = file
122 | break
123 | # Make sure we actually found a Hyper file to replace
124 | if existing_hyper_filename is None:
125 | print('No Hyper file found to replace, breaking')
126 | exit()
127 |
128 | t_file.set_file_for_replacement(filename_in_package=existing_hyper_filename,
129 | replacement_filname_on_disk=new_extract_filename)
130 | newly_saved_filename = t_file.save_new_file(filename_no_extension='Swapped Out Hyper')
131 | return newly_saved_filename
132 |
133 |
134 | # DISABLED IN INITIAL 5.0.0 release, could come back later. Better off building a template in Desktop and substituting
135 | #
136 | # Creating From Scratch
137 | #
138 | #def create_new_tds_for_two_table_extract(new_tds_filename, hyper_filename):
139 | # t_file = TableauFile(new_tds_filename, create_new=True, ds_version='10.5')
140 | # ds = t_file.tableau_document.datasources[0] # type: TableauDatasource
141 | # conn = ds.add_new_connection(ds_type='hyper', db_or_schema_name='Data/{}'.format(hyper_filename),
142 | # authentication='auth-none')
143 | # conn_obj = ds.connections[0] # type: TableauConnection
144 | # conn_obj.username = 'tableau_internal_user'
145 |
146 | # # Your actual logic here will vary depending on what you have named the tables and what they join on
147 | # ds.set_first_table('First Table', 'First Table', connection=conn, extract=True)
148 | # join_clause = ds.define_join_on_clause('First Table', 'join_key_id', '=', 'Second Table', 'join_key_id')
149 | # ds.join_table('Inner', 'Second Table', 'Second Table', [join_clause, ])
150 | # new_filename = t_file.save_new_file('Generated Hyper Final')
151 | # return new_filename
152 |
153 |
154 | # This is the functional example using the functions above
155 | def build_row_level_security_extract_file():
156 |
157 | # Definition of ODBC connection string, this one for PostgreSQL
158 | # pyodbc is generic and should work with anything
159 |
160 | db_server = 'a.postgres.lan'
161 | db_port = '5432'
162 | db_db = 'database_name'
163 | db_username = 'username'
164 | db_password = 'password'
165 | pg_conn_string = 'Driver={{PostgreSQL Unicode}};Server={};Port={};Database={};Uid={};Pwd={};'.format(
166 | db_server, db_port, db_db, db_username, db_password)
167 |
168 | fact_table_query = 'SELECT * FROM fact_table;'
169 |
170 | entitlement_table_query = 'SELECT * FROM entitlements;'
171 |
172 | # Create the Hyper File
173 | new_hyper_file = 'New Hyper File.hyper'
174 | results = hyper_create_two_tables(new_hyper_filename='New Hyper File.hyper', table_1_pyodbc_conn_string=pg_conn_string,
175 | table_1_query=fact_table_query, table_1_tableau_name='Facts',
176 | table_2_pyodbc_conn_string=pg_conn_string, table_2_query=entitlement_table_query,
177 | table_2_tableau_name='Entitlements')
178 |
179 | # Now you have a Hyper file which you can connect to in Desktop, which can then
180 | # define the JOINs to create a Tableau Data Source, including the JOIN definitions and calculations.
181 | # You can then publish that data source. But if you want to keep it updated, you need to push the updated Hyper file
182 | # into the TDSX file.
183 |
184 | new_packaged_filename = substitute_an_existing_extract(tableau_packaged_filename='My template file.tdsx',
185 | new_extract_filename=new_hyper_file)
186 |
187 | # Now publish it up to the Server
188 | t = TableauServerRest33(server='http://myTableauServer', username='', password='', site_content_url='some_site')
189 | t.signin()
190 | project_to_publish_to = t.projects.query_project(project_name_or_luid='Default')
191 | ds_luid = t.datasources.publish_datasource(ds_filename=new_packaged_filename, ds_name='My Datasource Name',
192 | project_obj=project_to_publish_to,overwrite=True, save_credentials=False)
193 | print('Published to server with LUID {}'.format(ds_luid))
194 |
195 |
--------------------------------------------------------------------------------
/examples/limited_rest_api_wrapping_tableau_rest_api.py:
--------------------------------------------------------------------------------
1 | from tableau_tools import *
2 | #import time
3 |
4 | # This script shows two example generic functions which utilize the RestTokensManager class
5 | # It is an example of how you can create a wrapper REST API which exposes some of the
6 | # Tableau REST API functionality but not all of it, while using the impersonation feature
7 | # so that each request is performed for the User, without needing their credentials (only admin credentials)
8 | # Taken from a Django project, but Flask code would work similarly
9 |
10 | # You would probably store your admin credentials securely in an settings or ENV file
11 | server = settings.TABLEAU_SERVER
12 | admin_username = settings.TABLEAU_ADMIN_USERNAME
13 | admin_password = settings.TABLEAU_ADMIN_PASSWORD
14 | # This is most likely just 'default' but you might have some reason not to bootstrap from there
15 | default_site_content_url = settings.TABLEAU_DEFAULT_SITE_CONTENT_URL
16 |
17 | tableau_tools_logger = Logger('tableau_tools.log')
18 |
19 | # Connect to the default site to bootstrap the process
20 |
21 | # In a running app server, the same process is always running so it needs a Master login PER site
22 | # But also, REST API sessions do timeout, so we need to check for that possibility and remove
23 | # Sessions once they have timed out
24 |
25 | # You must use an Admin Username and Password, as PAT does not have Impersonation at this time (2020.2)
26 | d = TableauServerRest(server=server, username=admin_username, password=admin_password,
27 | site_content_url=default_site_content_url)
28 | # alternatively could use the older TableauRestApiConnection objects if you had code built on those objects
29 |
30 | # If you are using a self-signed cert or need to pass in a CERT chain, this pass directly to
31 | # the requests library https://requests.readthedocs.io/en/master/user/quickstart/ to do whatever SSL option you need:
32 | # d.verify_ssl_cert = False
33 |
34 | d.enable_logging(tableau_tools_logger)
35 | # Other options you might turn off for deeper logging:
36 | # tableau_tools_logger.enable_request_logging()
37 | # tableau_tools_logger.enable_response_logging()
38 | # tableau_tools_logger.enable_debug_level()
39 |
40 |
41 | # This manages all the connection tokens here on out
42 | connections = RestTokensManager()
43 |
44 | #
45 | # RestTokensManager methods are all functional -- you pass in a TableauServerRest or TableauRestApiConnection object
46 | # and then it perhaps actions on that object, such as logging in as a different user or switching to
47 | # an already logged in user.
48 | # Internally it maintains a data structure with the Admin tokens for any site that has been signed into
49 | # And the individual User Tokens for any User / Site combination that has been signed into
50 | # It does not RUN any REST API commands other than sign-in: You run those commands on the
51 | # connection object once it has been returned
52 |
53 | # For example, once this is run, the connection object 'd' will have been signed in, and you can
54 | # do any REST API command against 'd', and it will be done as the master on the default site
55 | # This is just bootstrapping at the very beginning to make sure we've connected successfully
56 | # with the admin credentials. If there are errors at this point, something is likely wrong
57 | # with the configuration/credentials or the Tableau Server
58 | default_token = connections.sign_in_connection_object(d)
59 |
60 | # Next is a generic_request function (based on Django pattern), that utilizes the connections object
61 |
62 | # Every one of our REST methods follows basically this pattern
63 | # So it has been made generic
64 | # You pass the callback function to do whatever you want with
65 | # the REST API object and whatever keyword arguments it needs
66 | # Callback returns a valid type of HttpResponse object and we're all good
67 | def generic_request(request, site, callback_function, **kwargs):
68 | # Generic response to start. This will be returned if no other condition overwrites it
69 | response = HttpResponseServerError()
70 |
71 | # If request is none, then it is an admin level function
72 | if request is not None:
73 | # Check user, if non, response is Http Forbidden
74 | # This function represents whatever your application needs to do to tell you the user who has logged in securely
75 | username = check_user_session(request)
76 | if username is None:
77 | response = HttpResponseForbidden()
78 | return response
79 | else:
80 | # If username is none, the request is run as the Site Admin
81 | username = None
82 |
83 | # Create Connection Object for Given User
84 | # Just create, but don't sign in. Will use swap via the TokenManager
85 | t = TableauServerRest32(server=server, username=admin_username, password=admin_password,
86 | site_content_url=default_site_content_url)
87 |
88 | # Again, you might need to pass in certain arguments to requests library if using a self-signed cert
89 | #t.verify_ssl_cert = False
90 | t.enable_logging(tableau_tools_logger)
91 |
92 | # Check for connection, attempt to reestablish if possible
93 | if connections.connection_signed_in is False:
94 | tableau_tools_logger.log("Signing back in to the master user")
95 | # If the reconnection fails, return Server Error response
96 | if connections.sign_in_connection_object(rest_connection=t) is False:
97 | # This is a Django error response, take it as whatever HTTP error you'd like to throw
98 | response = HttpResponseServerError()
99 | # If connection is already confirmed, just swap to the user token for the site
100 | else:
101 | # Site Admin level request
102 | if username is None:
103 | tableau_tools_logger.log("Swapping to Site Admin ")
104 | connections.switch_to_site_master(rest_connection=t, site_content_url=site)
105 | tableau_tools_logger.log("Token is now {}".format(t.token))
106 | # Request as a particular username
107 | else:
108 | tableau_tools_logger.log("Swapping in existing user token for user {}".format(username))
109 | connections.switch_user_and_site(rest_connection=t, username=username, site_content_url=site)
110 | tableau_tools_logger.log("Token is now {}".format(t.token))
111 |
112 | # Do action with connection
113 | # Whatever callback function was specified will be called with RestApiConnection / TableauServerRest object as first argument
114 | # then any other kwargs in the order they were passed.
115 | # The callback function must return a Django HttpResponse (or related) object
116 | # But within the callback, 't' is the TableauServerRest or TableauRestApiConnection object with the token for the
117 | # particular user you want
118 | try:
119 | response = callback_function(t, **kwargs)
120 |
121 | except NotSignedInException as e:
122 | if username is None:
123 | tableau_tools_logger.log("Master REST API session on site {} has timed out".format(site))
124 | del connections.site_master_tokens[site]
125 | # Rerun the connection
126 | tableau_tools_logger.log("Creating new user token for site master")
127 | connections.switch_to_site_master(rest_connection=t, site_content_url=site)
128 | tableau_tools_logger.log("Token is now {}".format(t.token))
129 | else:
130 | tableau_tools_logger.log("User {} REST API session on vertical {} has timed out".format(username, site))
131 | del connections.site_user_tokens[site][username]
132 | # Rerun the connection
133 | tableau_tools_logger.log("Creating new user token for username {} on vertical {}".format(username, site))
134 | connections.switch_user_and_site(rest_connection=t, username=username, site_content_url=site)
135 | tableau_tools_logger.log("Token is now {}".format(t.token))
136 | # Rerun the orginal callback command
137 | tableau_tools_logger.log("Doing callback function again now that new token exists")
138 | response = callback_function(t, **kwargs)
139 | # Originally, the code looked at the following two exceptions. This is been replaced by looking at NotSignedInException
140 | # RecoverableHTTPException is an exception from tableau_tools, when it is known what the error represents
141 | # HTTPError is a Requests library exception, which might happen if tableau_tools doesn't wrap the particular error.
142 | # except (RecoverableHTTPException, HTTPError) as e:
143 | # if e.http_code == 401:
144 | except Exception as e:
145 | raise e
146 | # Destroy REST API Connection Object, which is just used within this code block
147 | del t
148 | # Return Response
149 | return response
150 |
151 | # There were originally separate functions but they shared enough code to be merged together
152 | def admin_request(request, site, callback_function, **kwargs):
153 | # We don't pass the 'request' here, because it would have the end user's username attached via the session
154 | # The point is that username ends up None in the generic_request call, forcing it to use the admin
155 | return generic_request(None, site, callback_function, **kwargs)
156 |
157 |
158 | #
159 | # Here is an example of an actual exposed endpoint
160 | #
161 |
162 | # This is what is passed in as the callback function - so rest_connection is the 't' object passed in by generic_request
163 | # Returns all of the Projects a user can see content in, alphabetically sorted
164 | def query_projects(rest_connection: TableauServerRest):
165 | p_sort = Sort('name', 'asc')
166 | p = rest_connection.query_projects_json(sorts=[p_sort, ])
167 | return JsonResponse(p)
168 |
169 | # An exposed endpoint linked to an actual URL
170 | def projects(request, site):
171 | #log("Starting to request all workbooks")
172 | # Note we are just wrapping the generic request (this one doesn't take keyword arguments, but anything after
173 | # 'query_projects' would be passed as an argument into the query_projects function (if it took arguments)
174 | response = generic_request(request, site, query_projects)
175 | return response
--------------------------------------------------------------------------------
/examples/modifying_users_immediately_before_sso.py:
--------------------------------------------------------------------------------
1 | from tableau_tools import *
2 | import datetime
3 |
4 | # This script is to show how to add or modify a user prior to SSO
5 | # The use case is for passing in properties to a user session in a secure way
6 | # When using Restricted (standard) Trusted Tickets, a user cannot see or modify their Full Name property,
7 | # which allows it to be repurposed as a stored of secure values set programmatically
8 | #
9 | # If you have Core based licensing, and no need to track users beyond just that momentary session, you can also
10 | # create usernames at will and include details on the username. This could also be useful if you use SAML or Unrestricted
11 | # trusted tickets (where the user can change the Full Name property,
12 | # although you do lose out on schedules and customization and such due to the transient nature of those sessions
13 |
14 | server_url = ""
15 | username = ""
16 | password = ""
17 | site_content_url = ""
18 |
19 | t_server = TableauServerRest35(server=server_url, username=username, password=password,
20 | site_content_url=site_content_url)
21 | t_server.signin()
22 |
23 | def update_user_with_fullname_properties(tableau_server: TableauServerRest, username: str,
24 | properties_on_fullname: List[str], delimiter: str):
25 | t = tableau_server
26 | final_fullname = delimiter.join(properties_on_fullname)
27 | t.users.update_user(username_or_luid=username, full_name=final_fullname)
28 |
29 | def create_a_temporary_user(tableau_server: TableauServerRest, username: str, other_properties_on_username: List[str],
30 | properties_on_fullname: List[str], delimiter: str) -> str:
31 | t = tableau_server
32 | if len(other_properties_on_username) > 0:
33 | full_list = [username, ]
34 | full_list.extend(other_properties_on_username)
35 | final_username = delimiter.join(full_list)
36 | else:
37 | final_username = username
38 | final_fullname = delimiter.join(properties_on_fullname)
39 | new_user_luid = t.users.add_user(username=final_username, fullname=final_fullname, site_role='Viewer')
40 | # Because we might be storing properties on the username, return it back to whatever is then going to SSO the user
41 | return final_username
42 |
43 | # Typically we don't delete users from Tableau Server, but just set them to Unlicensed.
44 | # See delete_old_unlicensed_users for a pattern for actually deleting them based on some filter criteria
45 | def unlicense_a_temporary_user(tableau_server: TableauServerRest, username: str):
46 | t = tableau_server
47 | t.users.unlicense_users(username_or_luid_s=username)
48 |
49 | # This logic gets actually removes any licensed user who didn't log in today.
50 | # Could be lengthened out for a long-term type cleanup script
51 | def delete_old_unlicensed_users(tableau_server: TableauServerRest):
52 | t = tableau_server
53 | today = datetime.datetime.now()
54 | offset_time = datetime.timedelta(days=1)
55 | time_to_filter_by = today - offset_time
56 | last_login_filter = t.url_filters.get_last_login_filter('lte', time_to_filter_by)
57 | site_role_f = t.url_filters.get_site_role_filter('Unlicensed')
58 | unlicensed_users_from_before_today = t.users.query_users(last_login_filter=last_login_filter,
59 | site_role_filter=site_role_f)
60 | users_dict = t.xml_list_to_dict(unlicensed_users_from_before_today)
61 | # users_dict is username : luid, so you just need the values but must cast to a list
62 | t.users.remove_users_from_site(list(users_dict.values()))
--------------------------------------------------------------------------------
/examples/move_extracts_from_server_to_server.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from tableau_tools import *
4 | import time
5 |
6 | # This example is for a specific use case:
7 | # When the Originating Tableau Server can connect to the live data source to generate the new extract, but the
8 | # Destination Tableau Server cannot. This particular pattern downloads the updated TDSX file from the Originating Server
9 | # then pushes it to the Destination Server without saving credentials. With no credentials (and no Extract Refresh
10 | # scheduled), the version on the Destination Server will remain static until another version is pushed
11 |
12 | o_server = 'http://'
13 | o_username = ''
14 | o_password = ''
15 | o_site_content_url = ''
16 |
17 | logger = Logger('move.log')
18 |
19 | d_server = 'http://'
20 | d_username = ''
21 | d_password = ''
22 | d_site_content_url = ''
23 |
24 |
25 | t = TableauServerRest28(server=o_server, username=o_username, password=o_password, site_content_url=o_site_content_url)
26 | t.signin()
27 | t.enable_logging(logger)
28 | downloaded_filename = 'File Name'
29 | wb_name_on_server = 'WB Name on Server'
30 | proj_name = 'Default'
31 | t.workbooks.download_workbook(wb_name_or_luid='WB Name on Server', filename_no_extension=downloaded_filename,
32 | proj_name_or_luid=proj_name)
33 |
34 | d = TableauServerRest28(d_server, d_username, d_password, d_site_content_url)
35 | d.signin()
36 | d.enable_logging(logger)
37 | proj = d.projects.query_project('Default')
38 | d.workbooks.publish_workbook(workbook_filename='{}.twbx'.format(downloaded_filename),
39 | workbook_name=wb_name_on_server, project_obj=proj,
40 | save_credentials=False, overwrite=True)
--------------------------------------------------------------------------------
/examples/permissions_auditing.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from tableau_tools import *
3 | import csv
4 |
5 | username = ''
6 | password = ''
7 | server = 'http://localhost'
8 |
9 | logger = Logger('permissions.log')
10 | default = TableauRestApiConnection28(server=server, username=username, password=password)
11 | default.enable_logging(logger)
12 | default.signin()
13 |
14 | with open('permissions_audit.txt', 'w', newline='') as output_file:
15 |
16 | # Get all sites content urls for logging in
17 | site_content_urls = default.query_all_site_content_urls()
18 |
19 | # Create CSV writer
20 | output_writer = csv.writer(output_file)
21 |
22 | # Headers
23 | headers = ['Site Content URL', 'Project Name', 'Project LUID', 'Are Permissions Locked?',
24 | 'Principal Type', 'Principal Name', 'Principal LUID']
25 |
26 | project_caps = Permissions.available_capabilities[default.api_version]['project']
27 | for cap in project_caps:
28 | headers.append(cap)
29 | workbook_caps = Permissions.available_capabilities[default.api_version]['workbook']
30 | for cap in workbook_caps:
31 | headers.append(cap)
32 | datasource_caps = Permissions.available_capabilities[default.api_version]['datasource']
33 | for cap in datasource_caps:
34 | headers.append(cap)
35 | output_writer.writerow(headers)
36 |
37 | for site_content_url in site_content_urls:
38 | t = TableauRestApiConnection28(server=server, username=username, password=password,
39 | site_content_url=site_content_url)
40 | t.enable_logging(logger)
41 | t.signin()
42 | projects = t.query_projects()
43 | projects_dict = t.xml_list_to_dict(projects)
44 | print(projects_dict)
45 | for project in projects_dict:
46 | # combined_permissions = luid : {type, name, proj, def_wb, def_ds}
47 | proj_obj = t.query_project(projects_dict[project])
48 |
49 | all_perms = proj_obj.query_all_permissions()
50 |
51 | for luid in all_perms:
52 | output_row = []
53 | all_perms_list = proj_obj.convert_all_permissions_to_list(all_perms[luid])
54 | if site_content_url is None:
55 | site_content_url = ''
56 | output_row.append(site_content_url)
57 | output_row.append(project)
58 | output_row.append(projects_dict[project])
59 | output_row.append(str(proj_obj.are_permissions_locked()))
60 | output_row.append(all_perms[luid]["type"])
61 | output_row.append(all_perms[luid]["name"])
62 | output_row.append(luid)
63 | output_row.extend(all_perms_list)
64 | output_writer.writerow(output_row)
65 |
66 |
67 |
68 |
69 |
70 |
71 |
--------------------------------------------------------------------------------
/examples/test_suite_tableau_documents.py:
--------------------------------------------------------------------------------
1 | from tableau_tools import *
2 | from tableau_tools.tableau_documents import *
3 |
4 | #
5 | # WIP and will be more fully built out in the future. See template_publish_sample.py for other uses as well as the README
6 | #
7 |
8 | def live_db_connection_changes():
9 | t_file: TDS = TableauFileManager.open(filename='Live PostgreSQL.tds')
10 | if isinstance(t_file, DatasourceFileInterface):
11 | print('Yeah I got data sources')
12 | print(t_file.file_type)
13 | # print(t_file.tableau_xml_file)
14 | print(t_file.tableau_document)
15 | print(t_file.datasources)
16 | for ds in t_file.datasources:
17 | print(ds)
18 | print(ds.connections)
19 | ds.ds_name = 'New Datasource Name'
20 | if ds.is_stored_proc is False:
21 | if ds.main_table_type == 'table':
22 | ds.tables.main_table_name = '[some other table]'
23 | elif ds.main_table_type == 'custom-sql':
24 | ds.tables.set_first_custom_sql('SELECT * FROM table t1 JOIN table2 t2 ON t1.id = t2.id AND t2.active_flag IS TRUE')
25 | # Stored proc case
26 | else:
27 | ds.tables.set_stored_proc(stored_proc_name='best_stored_proc')
28 | ds.tables.set_stored_proc_parameter_value_by_name('@Param1', 'ID190993')
29 | ds.tables.set_stored_proc_parameter_value_by_name('@Param2', 'West')
30 | for conn in ds.connections:
31 | conn.connection_name = 'Changed Connection Name'
32 | t_file.save_new_file('New TDS')
33 |
34 | t_file_2: TDSX = TableauFileManager.open(filename='First Datasource Revision.tdsx')
35 | print(t_file_2.file_type)
36 | print(t_file_2.tableau_xml_file)
37 | print(t_file_2.tableau_document)
38 | print(t_file_2.datasources)
39 | for ds in t_file_2.datasources:
40 | print(ds)
41 | print(ds.connections)
42 | t_file_2.save_new_file('New TDSX')
43 |
44 | t_file_3: TWBX = TableauFileManager.open(filename='First Workbook Revision.twbx')
45 | print(t_file_3.file_type)
46 | print(t_file_3.tableau_xml_file)
47 | print(t_file_3.tableau_document)
48 | print(t_file_3.datasources)
49 | for ds in t_file_3.datasources:
50 | print(ds)
51 | print(ds.connections)
52 | print(ds.ds_name)
53 | print(ds.is_published)
54 | for conn in ds.connections:
55 | print(conn.connection_name)
56 | print(conn.connection_type)
57 | t_file_3.save_new_file('New TWBX')
58 |
59 | def flat_file_connection_changes():
60 | pass
61 | def hyper_api_swap():
62 | pass
63 |
64 |
--------------------------------------------------------------------------------
/examples/user_sync_sample.py:
--------------------------------------------------------------------------------
1 | # This is example code showing a sync process from a database with users in it
2 | # It uses psycopg2 to connect to a PostgreSQL database
3 | # You can substitute in any source of the usernames and groups
4 | # The essential logic is that you do a full comparison on who should exist and who doesn't, and both add and remove
5 |
6 |
7 | import psycopg2.extensions
8 | import psycopg2
9 |
10 | from tableau_tools import *
11 |
12 | psycopg2.extensions.register_type(psycopg2.extensions.UNICODE)
13 | psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY)
14 |
15 | conn = psycopg2.connect(host='', database='', user='', password='')
16 | logger = Logger('')
17 |
18 | username = ''
19 | password = ''
20 | server = 'http://'
21 |
22 | t = TableauServerRest33(server=server, username=username, password=password, site_content_url='default')
23 | t.enable_logging(logger)
24 | t.signin()
25 |
26 | # Connect to the DB
27 | cur = conn.cursor()
28 |
29 | # Create Groups
30 | sql_statement = 'SELECT groups FROM permissions GROUP BY groups;'
31 | cur.execute(sql_statement)
32 |
33 | # Get all the groups on the Tableau Server
34 | groups = t.groups.query_groups()
35 | groups_dict = t.xml_list_to_dict(groups)
36 |
37 | # Loop through the results
38 | for row in cur:
39 | if row[0] not in groups_dict:
40 | print('Creating group {}'.format(row[0]))
41 | luid = t.groups.create_group(group_name=row[0])
42 | groups_dict[row[0]] = luid
43 |
44 | print(groups_dict)
45 |
46 | # Create all
47 |
48 | # Sync the users themselves
49 | sql_statement = 'SELECT user_id, user_name, groups FROM permissions'
50 | cur.execute(sql_statement)
51 |
52 | # Get all the users on the site
53 | users = t.users.query_users()
54 | users_dict = t.xml_list_to_dict(users)
55 |
56 | # Loop through users, make sure they exist
57 | for row in cur:
58 | if row[0] not in users_dict:
59 | print('Creating user {}'.format(row[0].encode('utf8')))
60 | luid = t.users.add_user(username=row[0], fullname=row[1], site_role='Publisher')
61 | users_dict[row[0]] = luid
62 |
63 | print(users_dict)
64 |
65 | # Create projects for each user
66 | for user in users_dict:
67 | proj_obj = t.projects.create_project("My Saved Reports - {}".format(user))
68 | user_luid = users_dict[user]
69 | perms_obj = proj_obj.get_permissions_obj(username_or_luid=user_luid, role='Publisher')
70 | proj_obj.set_permissions([perms_obj, ])
71 |
72 |
73 | # Reset back to beginning to reuse query
74 | cur.scroll(0, mode='absolute')
75 |
76 | # For the check of who shouldn't be on the server
77 | groups_and_users = {}
78 |
79 | # List of usernames who should be in the system
80 | usernames = {}
81 | # Add users who are missing from a group
82 | for row in cur:
83 | user_luid = users_dict.get(row[0])
84 | group_luid = groups_dict.get(row[2])
85 |
86 | usernames[row[0]] = None
87 |
88 | # Make a data structure where we can check each group that exists on server
89 | if groups_and_users.get(group_luid) is None:
90 | groups_and_users[group_luid] = []
91 | groups_and_users[group_luid].append(user_luid)
92 |
93 | print('Adding user {} to group {}'.format(row[0].encode('utf8'), row[2].encode('utf8')))
94 | t.groups.add_users_to_group(username_or_luid_s=user_luid, group_name_or_luid=group_luid)
95 |
96 | # Determine if any users are in a group who do not belong, then remove them
97 | for group_luid in groups_and_users:
98 | if group_luid == groups_dict['All Users']:
99 | continue
100 | users_in_group_on_server = t.groups.query_users_in_group(group_luid)
101 | users_in_group_on_server_dict = t.xml_list_to_dict(users_in_group_on_server)
102 | # values() are the LUIDs in these dicts
103 | for user_luid in list(users_in_group_on_server_dict.values()):
104 | if user_luid not in groups_and_users[group_luid]:
105 | print('Removing user {} from group {}'.format(user_luid, group_luid))
106 | t.groups.remove_users_from_group(username_or_luid_s=user_luid, group_name_or_luid=group_luid)
107 |
108 | # Determine if there are any users who are in the system and not in the database, set them to unlicsened
109 | users_on_server = t.users.query_users()
110 | for user_on_server in users_on_server:
111 | # Skip the guest user
112 | if user_on_server.get("name") == 'guest':
113 | continue
114 | if user_on_server.get("name") not in usernames:
115 | if user_on_server.get("siteRole") not in ['ServerAdministrator', 'SiteAdministrator']:
116 | print(('User on server {} not found in security table, set to Unlicensed'.format(user_on_server.get("name").encode('utf8'))))
117 | # Just set them to 'Unlicensed'
118 | t.users.update_user(username_or_luid=user_on_server.get("name"), site_role='Unlicensed')
119 |
--------------------------------------------------------------------------------
/logger.py:
--------------------------------------------------------------------------------
1 | import time
2 | import sys
3 | import xml.etree.ElementTree as ET
4 | from typing import Union, Any, Optional, List, Dict, Tuple
5 |
6 | # Logger has several modes
7 | # Default just shows REST URL requests
8 | # If you "enable_request_xml_logging", then it will show the full XML of the request
9 | # If you "enable_debugging mode", then the log will indent to show which calls are wrapped within another
10 | # "enabled_response_logging" will log the response
11 | class Logger(object):
12 | def __init__(self, filename):
13 | self._log_level = 'standard'
14 | self.log_depth = 0
15 | try:
16 | lh = open(filename, 'wb')
17 | self.__log_handle = lh
18 | except IOError:
19 | print("Error: File '{}' cannot be opened to write for logging".format(filename))
20 | raise
21 | self._log_modes = {'debug': False, 'response': False, 'request': False}
22 |
23 | def enable_debug_level(self):
24 | self._log_level = 'debug'
25 | self._log_modes['debug'] = True
26 |
27 | def enable_request_logging(self):
28 | self._log_modes['request'] = True
29 |
30 | def enable_response_logging(self):
31 | self._log_modes['response'] = True
32 |
33 | def log(self, l: str):
34 | cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
35 | if self.log_depth == 0:
36 | log_line = cur_time + " : " + l + "\n"
37 | else:
38 | log_line = " "*self.log_depth + " " + cur_time + " : " + l + "\n"
39 | try:
40 | self.__log_handle.write(log_line.encode('utf8'))
41 | except UnicodeDecodeError as e:
42 | self.__log_handle.write(log_line)
43 |
44 | def log_debug(self, l: str):
45 | if self._log_modes['debug'] is True:
46 | self.log(l)
47 |
48 | def start_log_block(self):
49 | caller_function_name = sys._getframe(2).f_code.co_name
50 | c = str(sys._getframe(2).f_locals["self"].__class__)
51 | class_path = c.split('.')
52 | short_class = class_path[len(class_path)-1]
53 | short_class = short_class[:-2]
54 | cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
55 |
56 |
57 | # Only move the log depth in debug mode
58 | if self._log_modes['debug'] is True:
59 | self.log_depth += 2
60 | log_line = '{}vv-- {} : {} {} started --------vv\n'.format(" " * self.log_depth, str(cur_time), short_class,
61 | caller_function_name)
62 | else:
63 | log_line = '{} : {} {} started --------vv\n'.format(str(cur_time), short_class, caller_function_name)
64 |
65 | self.__log_handle.write(log_line.encode('utf-8'))
66 |
67 | def end_log_block(self):
68 | caller_function_name = sys._getframe(2).f_code.co_name
69 | c = str(sys._getframe(2).f_locals["self"].__class__)
70 | class_path = c.split('.')
71 | short_class = class_path[len(class_path)-1]
72 | short_class = short_class[:-2]
73 | cur_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
74 | if self._log_modes['debug'] is True:
75 | self.log_depth -= 2
76 | log_line = '{}^^-- {} : {} {} ended --------^^\n'.format(" "*self.log_depth, str(cur_time), short_class, caller_function_name)
77 | else:
78 | log_line = '{} : {} {} ended --------^^\n'.format(str(cur_time), short_class, caller_function_name)
79 |
80 | self.__log_handle.write(log_line.encode('utf-8'))
81 |
82 | def log_uri(self, uri: str, verb: str):
83 | self.log('[{}] {}'.format(verb.upper(), uri))
84 |
85 | def log_xml_request(self, xml: Union[ET.Element, str], verb: str, uri: str):
86 | if self._log_modes['request'] is True:
87 | if isinstance(xml, str):
88 | self.log('[{}] \n{}'.format(verb.upper(), xml))
89 | else:
90 | self.log('[{}] \n{}'.format(verb.upper(), ET.tostring(xml)))
91 | else:
92 | self.log('[{}] {}'.format(verb.upper(), uri))
93 |
94 | def log_xml_response(self, xml: Union[str, ET.Element]):
95 | if self._log_modes['response'] is True:
96 | if isinstance(xml, str):
97 | self.log('[XML Response] \n{}'.format(xml))
98 | else:
99 | self.log('[XML Response] \n{}'.format(ET.tostring(xml)))
100 |
101 | def log_error(self, error_text: str):
102 | self.log('[ERROR] {}'.format(error_text))
--------------------------------------------------------------------------------
/logging_methods.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, List, Dict, Union
2 | import xml.etree.ElementTree as ET
3 |
4 | from .logger import Logger
5 |
6 | class LoggingMethods:
7 | # Logging Methods
8 | def enable_logging(self, logger_obj: Logger):
9 | self.logger = logger_obj
10 |
11 | def log(self, l: str):
12 | if self.logger is not None:
13 | self.logger.log(l)
14 |
15 | def log_debug(self, l: str):
16 | if self.logger is not None:
17 | self.logger.log_debug(l)
18 |
19 | def start_log_block(self):
20 | if self.logger is not None:
21 | self.logger.start_log_block()
22 |
23 | def end_log_block(self):
24 | if self.logger is not None:
25 | self.logger.end_log_block()
26 |
27 | def log_uri(self, uri: str, verb: str):
28 | if self.logger is not None:
29 | self.logger.log_uri(uri=uri, verb=verb)
30 |
31 | def log_xml_request(self, xml: ET.Element, verb: str, uri: str):
32 | if self.logger is not None:
33 | self.logger.log_xml_request(xml=xml, verb=verb, uri=uri)
34 |
35 | def log_xml_response(self, xml: Union[str, ET.Element]):
36 | if self.logger is not None:
37 | self.logger.log_xml_response(xml=xml)
38 |
39 | def log_error(self, error_text: str):
40 | if self.logger is not None:
41 | self.logger.log_error(error_text=error_text)
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
--------------------------------------------------------------------------------
/rest_tokens_manager.py:
--------------------------------------------------------------------------------
1 | from .tableau_exceptions import NotSignedInException, RecoverableHTTPException
2 | from .logging_methods import LoggingMethods
3 | from typing import Dict, List
4 | #
5 | # RestConnectionsManager class handles all of the session tokens, so that a single connection object
6 | # can send the requests for any user who is active
7 | # Eventually the token data structure could be put in the cache and shared, so that
8 | # multiple running processes could exist handling lots of traffic but sharing the session tokens
9 | #
10 | class RestTokensManager(LoggingMethods):
11 | # The tokens manager is separate from the connection object
12 | # It is not static, but all of its methods act UPON a REST Connection object, so you have to pass
13 | # that Rest Connection object in for every method
14 |
15 | # Then you define what you set in the constructor (when you create) here in the __init__
16 | # Must always start with self (the equivalent of "this" in Python)
17 | def __init__(self):
18 |
19 | # Each site should have a "master token" for signing in as the impersonated user the first time
20 | self.site_master_tokens = {} # site : token
21 | # This collection then holds any tokens from an individual user's session on a given site
22 | self.site_user_tokens = {} # site : { username : token }
23 |
24 | self.default_connection_token = None
25 | self.connection_signed_in = False
26 |
27 | self.sites_luids_dict = None
28 |
29 |
30 | def _sign_in_error(self):
31 | self.log("Tableau Server REST API Service unreachable")
32 | # Send an e-mail to settings.SYS_ADMIN_EMAIL_ALIAS if in Develop or Production Environment
33 | # IMPLEMENT
34 | # If basic rest_connection_object is None, then always return a "Service Not Available" response
35 | self.connection_signed_in = False
36 | return False
37 |
38 | # This method can sign in from the very beginning if necessary
39 | def _sign_in(self, rest_connection):
40 | rest_connection.signin()
41 | self.connection_signed_in = True
42 | self.default_connection_token = rest_connection
43 |
44 | # Query all the sites to allow for skipping a sign-in once you have the token
45 | sites = rest_connection.sites.query_sites()
46 | self.sites_luids_dict = {}
47 | for site in sites:
48 | self.sites_luids_dict[site.get('contentUrl')] = site.get('id')
49 | return True
50 |
51 | # Signs in to the default site with the Server Admin user credentials
52 | def sign_in_connection_object(self, rest_connection):
53 | # This is a failure within this code, not a failure to reach the Tableau Server and sign in
54 | if rest_connection is None:
55 | raise NotSignedInException()
56 |
57 | # Try to sign in to the Tableau Server REST API
58 | try:
59 | return self._sign_in(rest_connection)
60 |
61 | # Trying all these exception types than capturing anything, but probably should figure exactly what is wrong
62 | except NotSignedInException as e:
63 | try:
64 | return self._sign_in(rest_connection)
65 | except:
66 | return self._sign_in_error()
67 | # Try to sign-in again?
68 | except RecoverableHTTPException as e:
69 | try:
70 | return self._sign_in(rest_connection)
71 | except:
72 | return self._sign_in_error()
73 | # Should be capturing requests ConnectionError exception
74 | except Exception as e:
75 | try:
76 | return self._sign_in(rest_connection)
77 | except:
78 | return self._sign_in_error()
79 |
80 | # Signs in to a particular site with the Server Admin
81 | def sign_in_site_master(self, rest_connection, site_content_url):
82 | rest_connection.token = None
83 | rest_connection.site_content_url = site_content_url
84 | try:
85 | rest_connection.signin()
86 | except:
87 | try:
88 | rest_connection.signin()
89 | except:
90 | raise
91 |
92 | # Now grab that token
93 | self.site_master_tokens[site_content_url] = {"token": rest_connection.token,
94 | "user_luid": rest_connection.user_luid}
95 |
96 | # If no exist site
97 | if site_content_url not in self.site_user_tokens:
98 | self.site_user_tokens[site_content_url] = {}
99 |
100 | # And reset back to the default
101 | rest_connection.token = self.default_connection_token
102 | return True
103 |
104 | # All this check is if a user token exists
105 | def check_user_token(self, rest_connection, username, site_content_url):
106 | self.log('Checking user tokens. Current site master tokens are: {} . Current user tokens are: {}'.format(self.site_master_tokens, self.site_user_tokens))
107 |
108 | # Has the site been not used before? If not, create it
109 | if site_content_url not in self.site_master_tokens.keys():
110 | # If the site has no master token, create it
111 | # But we're keeping the same connection object, to limit the total number of tokens
112 | self.sign_in_site_master(rest_connection, site_content_url)
113 |
114 | # Also create an entry in the users dict for this site. The check is probably unnecessary but why not
115 | if site_content_url not in self.site_user_tokens.keys():
116 | self.site_user_tokens[site_content_url] = {}
117 | # No user token can exist if nothing even existed on that site yet
118 | return False
119 | # Do they have an entry?
120 | elif username in self.site_user_tokens[site_content_url].keys():
121 | # Now check if a token exists
122 | if self.site_user_tokens[site_content_url][username] is None:
123 | return False
124 | else:
125 | return True
126 | # Didn't find them, no connection
127 | else:
128 | return False
129 |
130 |
131 | def create_user_connection(self, rest_connection, username, site_content_url):
132 | # Swap to the master session for the site to get the user luid
133 |
134 | self.log("Swapping to master site token {}".format(self.site_master_tokens[site_content_url]["token"]))
135 | master_token = self.site_master_tokens[site_content_url]["token"]
136 | master_user_luid = self.site_master_tokens[site_content_url]["user_luid"]
137 | self.log("sites_luid_dict: {}".format(self.sites_luids_dict))
138 | master_site_luid = self.sites_luids_dict[site_content_url]
139 | self.log("Master Site LUID for swap is {} on site {}".format(master_site_luid, site_content_url))
140 | rest_connection.swap_token(site_luid=master_site_luid, user_luid=master_user_luid, token=master_token)
141 | # Needed for Signin commands
142 | rest_connection.site_content_url = site_content_url
143 | try:
144 | self.log("Trying to get user_luid for {}".format(username))
145 | user_luid = rest_connection.query_user_luid(username)
146 |
147 | except:
148 | # Retry at least once
149 | try:
150 | user_luid = rest_connection.query_user_luid(username)
151 | except:
152 | # Assume something wrong with the site_master token, create new session
153 | try:
154 | self.sign_in_site_master(rest_connection, site_content_url)
155 | rest_connection.token = self.site_master_tokens[site_content_url]["token"]
156 | user_luid = rest_connection.query_user_luid(username)
157 | except:
158 | # Maybe another check here?
159 | raise
160 |
161 |
162 | # Now blank the connection token so you can sign in
163 | rest_connection.token = None
164 | try:
165 | rest_connection.signin(user_luid)
166 | # Storing a dict here with the user_luid because it will be useful with swapping
167 | self.site_user_tokens[site_content_url][username] = {"token": rest_connection.token, "user_luid": user_luid}
168 | # Should this be exception instead of return of True?
169 | return True
170 | # This is bad practice to capture any exception, improve when we know what a "user not found" looks like
171 |
172 | except:
173 | # Try one more time in case it is connection issue
174 | try:
175 | rest_connection.signin(user_luid)
176 | self.site_user_tokens[site_content_url][username] = {"token": rest_connection.token, "user_luid": user_luid}
177 | except:
178 | # Should this be exception instead of return of True?
179 | return False
180 |
181 | def switch_user_and_site(self, rest_connection, username: str, site_content_url: str) -> Dict:
182 | user_exists = self.check_user_token(rest_connection, username, site_content_url)
183 | if user_exists is False:
184 | # Create the connection
185 | self.log("Had to create new user connection for {} on site {}".format(username, site_content_url))
186 | self.create_user_connection(rest_connection, username, site_content_url)
187 | self.log("Created connection. User {} on site {} has token {}".format(username, site_content_url, rest_connection.token))
188 | elif user_exists is True:
189 | # This token could be out of date, but test for that exception when you try to run a command
190 |
191 | self.log("Existing user connection found for {} on {}".format(username, site_content_url))
192 | token = self.site_user_tokens[site_content_url][username]["token"]
193 | user_luid = self.site_user_tokens[site_content_url][username]["user_luid"]
194 | site_luid = self.sites_luids_dict[site_content_url]
195 | self.log("Swapping connection to existing token {}".format(token))
196 | rest_connection.swap_token(site_luid=site_luid, user_luid=user_luid, token=token)
197 | self.log("RestAPi object token {} is now in place for user {} and site {}".format(rest_connection.token,
198 | rest_connection.user_luid,
199 | rest_connection.site_luid))
200 | else:
201 | raise Exception()
202 | # Return this here so it can be cached
203 | return self.site_user_tokens
204 |
205 | def switch_to_site_master(self, rest_connection, site_content_url):
206 | if site_content_url not in self.site_master_tokens.keys():
207 | self.sign_in_site_master(rest_connection, site_content_url)
208 |
209 | token = self.site_master_tokens[site_content_url]["token"]
210 | user_luid = self.site_master_tokens[site_content_url]["user_luid"]
211 | site_luid = self.sites_luids_dict[site_content_url]
212 | rest_connection.swap_token(site_luid=site_luid, user_luid=user_luid, token=token)
213 | # Return this here so it can be cached
214 | return self.site_master_tokens
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup(
4 | name='tableau_tools',
5 | python_requires='>=3.6',
6 | version='6.0.0',
7 | packages=['tableau_tools', 'tableau_tools.tableau_rest_api', 'tableau_tools.tableau_documents',
8 | 'tableau_tools.examples', 'tableau_tools.tableau_rest_api.methods'],
9 | url='https://github.com/bryanthowell-tableau/tableau_tools',
10 | license='',
11 | author='Bryant Howell',
12 | author_email='bhowell@tableau.com',
13 | description='A library for programmatically working with Tableau files and Tableau Server, including the REST API',
14 | install_requires=['requests']
15 | )
16 |
--------------------------------------------------------------------------------
/tabcmd.py:
--------------------------------------------------------------------------------
1 | import json
2 | import urllib.request, urllib.parse, urllib.error
3 | import xml.etree.ElementTree as ET
4 | import os
5 |
6 | from tableau_tools.tableau_repository import *
7 | from .tableau_http import *
8 | from .logger import Logger
9 | from .logging_methods import LoggingMethods
10 |
11 |
12 | class Tabcmd(LoggingMethods):
13 | def __init__(self, tabcmd_folder, tableau_server_url, username, password, site='default',
14 | repository_password=None, tabcmd_config_location=None):
15 | super(self.__class__, self).__init__()
16 | self.logger = None
17 |
18 | self.tabcmd_folder = tabcmd_folder
19 | self.username = username
20 | self.password = password
21 | self.site = site
22 | self.tableau_server_url = tableau_server_url
23 | self.current_trusted_ticket = None
24 |
25 | # user 'tableau' does not have enough rights
26 | self.repository_pw = repository_password
27 | self.user_session_id = None
28 | self.user_auth_token = None
29 | self.tabcmd_config_location = tabcmd_config_location
30 | self.tabcmd_config_filename = 'tabcmd-session.xml'
31 |
32 | # Configurable options for the exports
33 | self.export_pagesize = 'letter'
34 | self.export_page_layout = 'portrait'
35 | self.export_full_pdf = False
36 | self.export_width_pixels = 800
37 | self.export_height_pixels = 600
38 | self.export_type = None
39 |
40 | #
41 | # Wrapper commands for Tabcmd command line actions
42 | #
43 |
44 | def build_directory_cmd(self):
45 | return 'cd "{}" '.format(self.tabcmd_folder)
46 |
47 | def build_login_cmd(self, pw_filename):
48 |
49 | pw_file = open(pw_filename, 'w')
50 | pw_file.write(self.password)
51 | pw_file.close()
52 | if self.site.lower() == 'default':
53 | cmd = "tabcmd login -s {} -u {} --password-file \"{}\"".format(self.tableau_server_url,
54 | self.username, pw_filename)
55 | else:
56 | cmd = "tabcmd login -s {} -t {} -u {} --password-file \"{}\"".format(self.tableau_server_url, self.site,
57 | self.username, pw_filename)
58 |
59 | return cmd
60 |
61 | def build_export_cmd(self, export_type, filename, view_url, view_filter_map=None, refresh=False):
62 | # view_filter_map allows for passing URL filters or parameters
63 | if export_type.lower() not in ['pdf', 'csv', 'png', 'fullpdf']:
64 | raise InvalidOptionException('Should be pdf fullpdf csv or png')
65 | additional_url_params = ""
66 | if view_filter_map is not None:
67 | additional_url_params = "?" + urllib.parse.urlencode(view_filter_map)
68 | if refresh is True:
69 | additional_url_params += "&:refresh"
70 | elif view_filter_map is None:
71 | if refresh is True:
72 | additional_url_params += "?:refresh"
73 | view_url += additional_url_params
74 |
75 | cmd = 'tabcmd export "{}" --filename "{}" --{} --pagelayout {} --pagesize {} --width {} --height {}'.format(
76 | view_url, filename, export_type, self.export_page_layout, self.export_pagesize, self.export_width_pixels,
77 | self.export_height_pixels
78 | )
79 |
80 | return cmd
81 |
82 | @staticmethod
83 | def build_refreshextracts_cmd(project, workbook_or_datasource, content_pretty_name,
84 | incremental=False, workbook_url_name=None):
85 | project_cmd = '--project "{}"'.format(project)
86 | if project.lower() == 'default':
87 | project_cmd = ''
88 |
89 | inc_cmd = ''
90 | if incremental is True:
91 | inc_cmd = '--incremental'
92 |
93 | if workbook_url_name is not None:
94 | content_cmd = '--url {}'.format(workbook_url_name)
95 | else:
96 | if workbook_or_datasource.lower() == 'workbook':
97 | content_cmd = '--workbook "{}"'.format(content_pretty_name)
98 | elif workbook_or_datasource.lower() == 'datasource':
99 | content_cmd = '--datasource "{}"'.format(content_pretty_name)
100 | else:
101 | raise InvalidOptionException('workbook_or_datasource must be either workbook or datasource')
102 |
103 | cmd = 'tabcmd refreshextracts {} {} {}'.format(project_cmd, content_cmd, inc_cmd)
104 | return cmd
105 |
106 | @staticmethod
107 | def build_runschedule_cmd(schedule_name):
108 | cmd = 'tabcmd runschedule "{}"'.format(schedule_name)
109 | return cmd
110 |
111 | #
112 | # Methods to use
113 | #
114 | def create_export(self, export_type, view_location, view_filter_map=None,
115 | filename='tableau_workbook'):
116 | self.start_log_block()
117 | if self.export_type is not None:
118 | export_type = self.export_type
119 | if export_type.lower() not in ['pdf', 'csv', 'png', 'fullpdf']:
120 | raise InvalidOptionException('Options are pdf fullpdf csv or png')
121 | #
122 |
123 | directory_cmd = self.build_directory_cmd()
124 | # fullpdf still ends with pdf
125 | if export_type.lower() == 'fullpdf':
126 | saved_filename = '{}.{}'.format(filename, 'pdf')
127 | else:
128 | saved_filename = '{}.{}'.format(filename, export_type.lower())
129 |
130 | export_cmds = self.build_export_cmd(export_type, saved_filename, view_location, view_filter_map)
131 |
132 | temp_bat = open('export.bat', 'w')
133 |
134 | temp_bat.write(directory_cmd + "\n")
135 | temp_bat.write(export_cmds + "\n")
136 | temp_bat.close()
137 |
138 | os.system("export.bat")
139 | os.remove("export.bat")
140 | full_file_location = self.tabcmd_folder + saved_filename
141 | self.end_log_block()
142 | return full_file_location
143 |
144 | def trigger_extract_refresh(self, project, workbook_or_datasource, content_pretty_name, incremental=False,
145 | workbook_url_name=None):
146 | self.start_log_block()
147 | refresh_cmd = self.build_refreshextracts_cmd(project, workbook_or_datasource, content_pretty_name, incremental,
148 | workbook_url_name=workbook_url_name)
149 | temp_bat = open('refresh.bat', 'w')
150 | temp_bat.write(self.build_directory_cmd() + "\n")
151 | temp_bat.write(refresh_cmd + "\n")
152 | temp_bat.close()
153 |
154 | os.system("refresh.bat")
155 | os.remove("refresh.bat")
156 | self.end_log_block()
157 |
158 | def trigger_schedule_run(self, schedule_name):
159 | self.start_log_block()
160 | cmd = self.build_runschedule_cmd(schedule_name)
161 |
162 | temp_bat = open('runschedule.bat', 'w')
163 | temp_bat.write(self.build_directory_cmd() + "\n")
164 | temp_bat.write(cmd + "\n")
165 | temp_bat.close()
166 |
167 | os.system("runschedule.bat")
168 | os.remove("runschedule.bat")
169 | self.end_log_block()
170 |
--------------------------------------------------------------------------------
/tableau_documents/__init__.py:
--------------------------------------------------------------------------------
1 | #from .tableau_connection import TableauConnection
2 | #from .tableau_datasource import TableauDatasource
3 | #from .tableau_parameters import TableauParameters, TableauParameter
4 | # from .tableau_document import TableauDocument
5 | #from .tableau_columns import TableauColumns, TableauColumn
6 | from .tableau_file import *
7 | #from .tableau_workbook import TableauWorkbook
8 |
--------------------------------------------------------------------------------
/tableau_documents/hyper_file_generator.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from tableausdk import *
4 | from tableausdk.HyperExtract import *
5 | from tableau_tools.logging_methods import LoggingMethods
6 |
7 |
8 | class HyperFileGenerator(LoggingMethods):
9 | def __init__(self, logger_obj=None):
10 | super(self.__class__, self).__init__()
11 | self.logger = logger_obj
12 | self.field_setter_map = {
13 | Type.BOOLEAN: lambda row, col_num, value: row.setBoolean(col_num, value.lower() == "true"),
14 | Type.INTEGER: lambda row, col_num, value: row.setInteger(col_num, int(value)),
15 | Type.DOUBLE: lambda row, col_num, value: row.setDouble(col_num, float(value)),
16 | Type.UNICODE_STRING: lambda row, col_num, value: row.setString(col_num, value),
17 | Type.CHAR_STRING: lambda row, col_num, value: row.setCharString(col_num, value),
18 | Type.DATE: lambda row, col_num, value: self.set_date(row, col_num, value),
19 | Type.DATETIME: lambda row, col_num, value: self.set_date_time(row, col_num, value)
20 | }
21 | # Simple mapping of the string name of the Python type objects (accessed by __name__ property) to the TDE types
22 | self.python_type_map = {
23 | 'float': Type.DOUBLE,
24 | 'int': Type.INTEGER,
25 | 'unicode': Type.UNICODE_STRING,
26 | 'str': Type.CHAR_STRING,
27 | 'datetime': Type.DATETIME,
28 | 'boolean': Type.BOOLEAN,
29 | 'date': Type.DATE
30 | }
31 |
32 | self.table_definition = None
33 | self.tde_object = None
34 |
35 | @staticmethod
36 | def set_date(row, col_num, value):
37 | # d = datetime.datetime.strptime(value, "%Y-%m-%d")
38 | d = value
39 | row.setDate(col_num, d.year, d.month, d.day)
40 |
41 | @staticmethod
42 | def set_date_time(row, col_num, value):
43 | # if( value.find(".") != -1) :
44 | # d = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S.%f")
45 | # else :
46 | # d = datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
47 | d = value
48 | row.setDateTime(col_num, d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond / 100)
49 |
50 | def set_table_definition(self, column_name_type_dict, collation=Collation.EN_US):
51 | self.table_definition = TableDefinition()
52 |
53 | # Assuming EN_US, should be made optional
54 | self.table_definition.setDefaultCollation(collation)
55 |
56 | for col in column_name_type_dict:
57 | self.table_definition.addColumn(col, self.python_type_map[column_name_type_dict[col]])
58 | return self.table_definition
59 |
60 | def create_table_definition_from_pyodbc_cursor(self, pydobc_cursor, collation=Collation.EN_US):
61 | self.table_definition = TableDefinition()
62 | # Assuming EN_US, should be made optional
63 | self.table_definition.setDefaultCollation(collation)
64 |
65 | # cursor.description is collection to find information about the returned columns
66 | # for example, the column names and the datatypes.
67 |
68 | for col in pydobc_cursor.description:
69 | # [0] is the column name string, [1] is the python type object
70 | print((col[0].decode('utf-8')))
71 | self.log('Adding {} {}'.format(col[0], col[1].__name__))
72 | # Second item is a Python Type object, to get the actual name as a string for comparison, have to use __name__ property
73 | # Check against the type maps, drop the column if we don't understand the types
74 | if col[1].__name__ in self.python_type_map:
75 | self.table_definition.addColumn(col[0], self.python_type_map[col[1].__name__])
76 | else:
77 | self.log('Skipped column {}, {}'.format(col[0], col[1].__name__))
78 | return self.table_definition
79 |
80 | # def create_table_definition_from_pandas_dataframe(self, pandas_dataframe, collation=Collation.EN_US):
81 |
82 |
83 | def create_extract(self, tde_filename, append=False, table_name='Extract', pyodbc_cursor=None):
84 | try:
85 | # Using "with" handles closing the TDE correctly
86 |
87 | with Extract("{}".format(tde_filename)) as extract:
88 | self.tde_object = None
89 | row_count = 0
90 | # Create the Extract object (or set it for updating) if there are actually results
91 | if not extract.hasTable(table_name):
92 | # Table does not exist; create it
93 | self.log('Creating Extract with table definition')
94 | self.tde_object = extract.addTable(table_name, self.table_definition)
95 | else:
96 | # Open an existing table to add more rows
97 | if append is True:
98 | self.tde_object = extract.openTable(table_name)
99 | else:
100 | self.log("Output file '{}' already exists.".format(tde_filename))
101 | self.log("Append mode is off, please delete file and then rerun...")
102 | sys.exit()
103 | # This is if you actually have data to put into the extract. Implement later
104 | if pyodbc_cursor is not None:
105 | row_count = 0
106 | for db_row in pyodbc_cursor:
107 | tde_row = Row(self.table_definition)
108 | col_no = 0
109 | for field in db_row:
110 | # Possible for database to have types that do not map, we skip them
111 | if pyodbc_cursor.description[col_no][1].__name__ in self.python_type_map:
112 | if field == "" or field is None:
113 | tde_row.setNull(col_no)
114 | else:
115 | # From any given row from the cursor object, we can use the cursor_description collection to find information
116 | # for example, the column names and the datatypes. [0] is the column name string, [1] is the python type object. Mirrors cursor.description on the Row level
117 | # Second item is a Python Type object, to get the actual name as a string for comparison, have to use __name__ property
118 | self.field_setter_map[self.python_type_map[pyodbc_cursor.description[col_no][1].__name__ ]](tde_row, col_no, field)
119 | col_no += 1
120 | self.tde_object.insert(tde_row)
121 | row_count += 1
122 | self.log("Hyper creation complete, {} rows inserted".format(row_count))
123 | #if len(skipped_cols) > 0:
124 | # self.log(u"The following columns were skipped due to datatypes that were not recognized: ")
125 | # self.log(unicode(skipped_cols))
126 |
127 | except TableauException as e:
128 | self.log('Tableau Hyper creation error:{}'.format(e))
129 | raise
130 |
131 |
--------------------------------------------------------------------------------
/tableau_documents/tableau_columns.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import Union, Any, Optional, List, Dict, Tuple
3 | import xml.etree.ElementTree as ET
4 |
5 | # from tableau_tools.tableau_base import TableauBase
6 | from tableau_tools.tableau_exceptions import *
7 | from tableau_tools.logger import Logger
8 | from tableau_tools.logging_methods import LoggingMethods
9 |
10 | from .tableau_parameters import TableauParameter
11 |
12 |
13 | class TableauColumns(LoggingMethods):
14 | def __init__(self, columns_list: List[ET.Element], logger_obj: Optional[Logger] = None):
15 | self.logger = logger_obj
16 | self.log_debug('Initializing a TableauColumns object')
17 | self.columns_list: List[ET.Element] = columns_list
18 |
19 | def translate_captions(self, translation_dict: Dict):
20 | self.start_log_block()
21 | for column in self.columns_list:
22 | if column.get('caption') is None:
23 | trans = translation_dict.get(column.get('name'))
24 | else:
25 | # Try to match caption first, if not move to name
26 | trans = translation_dict.get(column.get('caption'))
27 | if trans is None:
28 | trans = translation_dict.get(column.get('name'))
29 | if trans is not None:
30 | column.set('caption', trans)
31 | self.end_log_block()
32 |
33 | def get_column_by_name(self, column_name: str) -> TableauParameter:
34 | for c in self.columns_list:
35 | if c.get('name') == '[{}]]'.format(column_name) or c.get('caption') == column_name:
36 | return c
37 | else:
38 | raise NoMatchFoundException('No column named {}'.format(column_name))
39 |
40 |
41 | class TableauColumn(LoggingMethods):
42 | def __init__(self, column_xml_obj: ET.Element, logger_obj: Optional[Logger] = None):
43 | self.logger = logger_obj
44 | self.log_debug('Initializing TableauColumn object')
45 | self.xml_obj = column_xml_obj
46 |
47 | @property
48 | def alias(self) -> str:
49 | return self.xml_obj.get('caption')
50 |
51 | @alias.setter
52 | def alias(self, alias: str):
53 | self.xml_obj.set('caption', alias)
54 |
55 | @property
56 | def datatype(self) -> str:
57 | return self.xml_obj.get('datatype')
58 |
59 | @datatype.setter
60 | def datatype(self, datatype: str):
61 | if datatype.lower() not in ['string', 'integer', 'datetime', 'date', 'real', 'boolean']:
62 | raise InvalidOptionException("{} is not a valid datatype".format(datatype))
63 | self.xml_obj.set('datatype', datatype)
64 |
65 | @property
66 | def column_name(self) -> str:
67 | return self.xml_obj.get('name')
68 |
69 | @column_name.setter
70 | def column_name(self, column_name: str):
71 | if column_name[0] == "[" and column_name[-1] == "]":
72 | new_column_name = column_name
73 | else:
74 | new_column_name = "[{}]".format(column_name)
75 | self.xml_obj.set('name', new_column_name)
76 |
77 | @property
78 | def dimension_or_measure(self) -> str:
79 | return self.xml_obj.get('role')
80 |
81 | @dimension_or_measure.setter
82 | def dimension_or_measure(self, dimension_or_measure: str):
83 | final_dimension_or_measure = dimension_or_measure.lower()
84 | if final_dimension_or_measure not in ['dimension', 'measure']:
85 | raise InvalidOptionException('dimension_or_measure must be "dimension" or "measure"')
86 | self.xml_obj.set('role', final_dimension_or_measure)
87 |
88 | @property
89 | def aggregation_type(self) -> str:
90 | return self.xml_obj.get('type')
91 |
92 | @aggregation_type.setter
93 | def aggregation_type(self, aggregation_type: str):
94 | final_aggregation_type = aggregation_type.lower()
95 | if final_aggregation_type not in ['ordinal', 'nominal', 'quantitative']:
96 | raise InvalidOptionException('aggregation_type must be "ordinal", "nominal" or "quantiative"')
97 | self.xml_obj.set('type', final_aggregation_type)
98 |
99 |
100 | class TableauHierarchies(LoggingMethods):
101 | def __init__(self, hierarchies_xml: ET.Element, logger_obj: Optional[Logger] = None):
102 | self.logger = logger_obj
103 | self.log_debug('Initializing TableauHierarchies object')
104 | self.xml_obj = hierarchies_xml
105 | self.hierarchies = self.xml_obj.findall('./drill-path')
106 |
107 | def get_hierarchy_by_name(self, hierarchy_name: str):
108 | for h in self.hierarchies:
109 | if h.get('name') == hierarchy_name:
110 | return h
111 | else:
112 | raise NoMatchFoundException('No hierarchy named {}'.format(hierarchy_name))
113 |
114 |
115 | class TableauHierarchy(LoggingMethods):
116 | def __init__(self, hierarchy_xml: ET.Element, logger_obj: Optional[Logger] = None):
117 | self.logger = logger_obj
118 | self.log_debug('Initializing TableauHierarchies object')
119 | self.xml_obj: ET.Element = hierarchy_xml
120 | self._name: str = self.xml_obj.get('name')
121 | self._fields: List[str] = []
122 | for f in self.xml_obj:
123 | self._fields.append(f.text)
124 |
125 | @property
126 | def name(self) -> str:
127 | return self._name
128 |
129 | @name.setter
130 | def name(self, new_name: str):
131 | self.xml_obj.set('name', new_name)
132 |
133 | @property
134 | def fields(self) -> List[str]:
135 | return self._fields
136 |
137 | def set_existing_field(self, field_position: int, field_value: str):
138 | if field_position < 0:
139 | raise InvalidOptionException('Field position must be positive integer')
140 | if field_position >= len(self._fields):
141 | raise InvalidOptionException('Only {} fields, field_position {} too high'.format(len(self._fields),
142 | field_position))
143 | if field_value[0] == "[" and field_value[-1] == "]":
144 | self._fields[field_position] = field_value
145 | else:
146 | self._fields[field_position] = "[{}]".format(field_value)
147 |
148 | def add_field(self, field_value: str):
149 | if field_value[0] == "[" and field_value[-1] == "]":
150 | self._fields.append(field_value)
151 | else:
152 | self._fields.append("[{}]".format(field_value))
153 |
154 | def remove_field(self, field_position: int):
155 | if field_position < 0:
156 | raise InvalidOptionException('Field position must be positive integer')
157 | if field_position >= len(self._fields):
158 | raise InvalidOptionException('Only {} fields, field_position {} too high'.format(len(self._fields),
159 | field_position))
160 | del self._fields[field_position]
161 |
--------------------------------------------------------------------------------
/tableau_documents/tableau_connection.py:
--------------------------------------------------------------------------------
1 | import xml.etree.ElementTree as ET
2 | from typing import Union, Any, Optional, List, Dict, Tuple
3 |
4 | # from tableau_tools.tableau_base import *
5 | from tableau_tools.tableau_exceptions import *
6 | from tableau_tools.logger import Logger
7 | from tableau_tools.logging_methods import LoggingMethods
8 |
9 |
10 | # Represents the actual Connection tag of a given datasource
11 | class TableauConnection(LoggingMethods):
12 | def __init__(self, connection_xml_obj: ET.Element, logger_obj: Optional[Logger] = None):
13 | #TableauBase.__init__(self)
14 | self.logger = logger_obj
15 | self.connection_name = None
16 | # Differentiate between named-connection and connection itself
17 | if connection_xml_obj.tag == 'named-connection':
18 | self.connection_name = connection_xml_obj.get('name')
19 | self.xml_obj = connection_xml_obj.find('connection')
20 | else:
21 | self.xml_obj = connection_xml_obj
22 |
23 | @property
24 | def cols(self) -> ET.Element:
25 | return self.xml_obj.find('cols')
26 |
27 | @property
28 | def dbname(self) -> Optional[str]:
29 | # Looks for schema tag as well in case it's an Oracle system (potentially others)
30 | if self.connection_type in ['oracle', ]:
31 | return self.xml_obj.get('schema')
32 | elif self.xml_obj.get('dbname'):
33 | return self.xml_obj.get('dbname')
34 | else:
35 | return None
36 |
37 | @dbname.setter
38 | def dbname(self, new_db_name: str):
39 | # Potentially could be others with the oracle issue, for later
40 | if self.connection_type in ['oracle', ]:
41 | self.xml_obj.set('schema', new_db_name)
42 | else:
43 | self.xml_obj.set('dbname', new_db_name)
44 |
45 | @property
46 | def schema(self) -> Optional[str]:
47 |
48 | if self.xml_obj.get("schema") is not None:
49 | return self.xml_obj.get('schema')
50 | # This is just in case you are trying schema with dbname only type database
51 | else:
52 | return self.xml_obj.get('dbname')
53 |
54 | @schema.setter
55 | def schema(self, new_schema: str):
56 | if self.xml_obj.get("schema") is not None:
57 | self.xml_obj.set('schema', new_schema)
58 | # This is just in case you are trying schema with dbname only type database
59 | else:
60 | self.dbname = new_schema
61 |
62 |
63 | @property
64 | def server(self) -> str:
65 | return self.xml_obj.get("server")
66 |
67 | @server.setter
68 | def server(self, new_server: str):
69 | if self.xml_obj.get("server") is not None:
70 | self.xml_obj.attrib["server"] = new_server
71 | else:
72 | self.xml_obj.set('server', new_server)
73 |
74 | @property
75 | def port(self) -> str:
76 | return self.xml_obj.get("port")
77 |
78 | @port.setter
79 | def port(self, new_port: str):
80 | if self.xml_obj.get("port") is not None:
81 | self.xml_obj.attrib["port"] = str(new_port)
82 | else:
83 | self.xml_obj.set('port', str(new_port))
84 |
85 | @property
86 | def connection_type(self) -> str:
87 | return self.xml_obj.get('class')
88 |
89 | @connection_type.setter
90 | def connection_type(self, new_type: str):
91 | if self.xml_obj.get("class") is not None:
92 | self.xml_obj.attrib["class"] = new_type
93 | else:
94 | self.xml_obj.set('class', new_type)
95 |
96 | def is_windows_auth(self) -> bool:
97 | if self.xml_obj.get("authentication") is not None:
98 | if self.xml_obj.get("authentication") == 'sspi':
99 | return True
100 | else:
101 | return False
102 |
103 | @property
104 | def filename(self) -> str:
105 | if self.xml_obj.get('filename') is None:
106 | raise NoResultsException('Connection type {} does not have filename attribute'.format(
107 | self.connection_type))
108 | else:
109 | return self.xml_obj.get('filename')
110 |
111 | @filename.setter
112 | def filename(self, filename: str):
113 | if self.xml_obj.get('filename') is not None:
114 | self.xml_obj.attrib['filename'] = filename
115 | else:
116 | self.xml_obj.set('filename', filename)
117 |
118 | @property
119 | def sslmode(self) -> str:
120 | return self.xml_obj.get('sslmode')
121 |
122 | @sslmode.setter
123 | def sslmode(self, value: str = 'require'):
124 | if self.xml_obj.get('sslmode') is not None:
125 | self.xml_obj.attrib["sslmode"] = value
126 | else:
127 | self.xml_obj.set('sslmode', value)
128 |
129 | @property
130 | def authentication(self) -> str:
131 | return self.xml_obj.get('authentication')
132 |
133 | @authentication.setter
134 | def authentication(self, auth_type: str):
135 | if self.xml_obj.get("authentication") is not None:
136 | self.xml_obj.attrib["authentication"] = auth_type
137 | else:
138 | self.xml_obj.set("authentication", auth_type)
139 |
140 | @property
141 | def service(self) -> str:
142 | return self.xml_obj.get('service')
143 |
144 | @service.setter
145 | def service(self, service: str):
146 | if self.xml_obj.get("service") is not None:
147 | self.xml_obj.attrib["service"] = service
148 | else:
149 | self.xml_obj.set("service", service)
150 |
151 | @property
152 | def username(self) -> str:
153 | return self.xml_obj.get('username')
154 |
155 | @username.setter
156 | def username(self, username: str):
157 | if self.xml_obj.get('username') is not None:
158 | self.xml_obj.attrib['username'] = username
159 | else:
160 | self.xml_obj.set('username', username)
161 |
162 |
--------------------------------------------------------------------------------
/tableau_documents/tableau_document.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import Union, Any, Optional, List, Dict, Tuple
3 | from abc import ABC, abstractmethod
4 |
5 | class TableauDocument(ABC):
6 |
7 | @abstractmethod
8 | def get_xml_string(self) -> str:
9 | pass
10 |
11 |
--------------------------------------------------------------------------------
/tableau_documents/tableau_workbook.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 | import codecs
4 | import xml.etree.ElementTree as ET
5 | from typing import Union, Any, Optional, List, Dict, Tuple
6 | import io
7 |
8 | from tableau_tools.logging_methods import LoggingMethods
9 | from tableau_tools.logger import Logger
10 | from tableau_tools.tableau_exceptions import *
11 |
12 | # TableauDocument defines ABC - interface
13 | from .tableau_document import TableauDocument
14 | from .tableau_datasource import TableauDatasource
15 | from .tableau_parameters import TableauParameters
16 |
17 |
18 | # Historically, this was just a file wrapper. That functionality has moved to the TWB class
19 | # This is now a stub for any eventual XML modification within the workbook
20 | class TableauWorkbook(LoggingMethods, TableauDocument):
21 | def __init__(self, twb_filename: str, logger_obj: Optional[Logger] = None):
22 | #TableauDocument.__init__(self)
23 | self.document_type = 'workbook'
24 | self.parameters = None
25 | self.logger = logger_obj
26 | self.log('Initializing a TableauWorkbook object')
27 | self.twb_filename = twb_filename
28 | # Check the filename
29 | if self.twb_filename.find('.twb') == -1:
30 | raise InvalidOptionException('Must input a .twb filename that exists')
31 | self.datasources: List[TableauDatasource] = []
32 |
33 | def build_datasource_objects(self, datasource_xml: ET.Element):
34 | datasource_elements = datasource_xml.getroot().findall('datasource')
35 | if datasource_elements is None:
36 | raise InvalidOptionException('Error with the datasources from the workbook')
37 | for datasource in datasource_elements:
38 | if datasource.get('name') == 'Parameters':
39 | self.log('Found embedded Parameters datasource, creating TableauParameters object')
40 | self.parameters = TableauParameters(datasource, self.logger)
41 | else:
42 | ds = TableauDatasource(datasource, self.logger)
43 | self.datasources.append(ds)
44 |
45 | def add_parameters_to_workbook(self) -> TableauParameters:
46 | if self.parameters is not None:
47 | return self.parameters
48 | else:
49 | self.parameters = TableauParameters(logger_obj=self.logger)
50 | return self.parameters
51 |
52 | def get_xml_string(self) -> str:
53 | self.start_log_block()
54 | try:
55 | orig_wb = codecs.open(self.twb_filename, 'r', encoding='utf-8')
56 |
57 | # Handle all in-memory as a file-like object
58 | lh = io.StringIO()
59 | # Stream through the file, only pulling the datasources section
60 | ds_flag = None
61 |
62 | for line in orig_wb:
63 | # Skip the lines of the original datasource and sub in the new one
64 | if line.find("") != -1 and ds_flag is True:
73 | self.log('Adding in the newly modified datasources')
74 | ds_flag = False
75 | lh.write('\n')
76 |
77 | final_datasources = []
78 | if self.parameters is not None:
79 | self.log('Adding parameters datasource back in')
80 | final_datasources.append(self.parameters)
81 | final_datasources.extend(self.datasources)
82 | else:
83 | final_datasources = self.datasources
84 | for ds in final_datasources:
85 | self.log('Writing datasource XML into the workbook')
86 | ds_string = ds.get_xml_string()
87 | if isinstance(ds_string, bytes):
88 | final_string = ds_string.decode('utf-8')
89 | else:
90 | final_string = ds_string
91 | lh.write(final_string)
92 | lh.write('\n')
93 | # Reset back to the beginning
94 | lh.seek(0)
95 | final_xml_string = lh.getvalue()
96 | lh.close()
97 | self.end_log_block()
98 | return final_xml_string
99 |
100 | except IOError:
101 | self.log("Error: File '{} cannot be opened to read from".format(self.twb_filename))
102 | self.end_log_block()
103 | raise
--------------------------------------------------------------------------------
/tableau_emailer.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | # Import smtplib for the actual sending function
4 | import smtplib
5 | # Import the email modules we'll need
6 | from email.mime.multipart import MIMEMultipart
7 | from email.mime.text import MIMEText
8 | from email import Encoders
9 | from email.mime.base import MIMEBase
10 |
11 | import os
12 | from os.path import basename
13 | from .tableau_repository import TableauRepository
14 | from .tabcmd import Tabcmd
15 |
16 |
17 | class TableauEmailer:
18 | def __init__(self, tabcmd_dir, tabcmd_config_location, repository_pw,
19 | tableau_server_url, tableau_server_admin_user, tableau_server_admin_pw,
20 | smtp_server, smtp_username=None, smtp_password=None):
21 |
22 | self.tableau_server_url = tableau_server_url
23 | self.repository_pw = repository_pw
24 | self.smtp_server = smtplib.SMTP(smtp_server)
25 | if smtp_username is not None and smtp_password is not None:
26 | self.smtp_server.login(smtp_username, smtp_password)
27 |
28 | # Create the tabcmd obj so you can do some querying. We will change the site later on at will
29 | self.tabcmd = Tabcmd(tabcmd_dir, tableau_server_url, tableau_server_admin_user, tableau_server_admin_pw,
30 | repository_password=repository_pw, tabcmd_config_location=tabcmd_config_location)
31 |
32 | def email_file_from_template(self, from_user, to_user, subject, template_name, filename_to_attach):
33 | # Every template_name should have two versions, a .html and a .txt fallback
34 | msg = MIMEMultipart('alternative')
35 | msg['Subject'] = subject
36 | msg['From'] = from_user
37 | msg['To'] = to_user
38 | text_fh = open(template_name + '.txt')
39 | html_fh = open(template_name + '.html')
40 | part1 = MIMEText(text_fh.read(), 'plain')
41 | part2 = MIMEText(html_fh.read(), 'html')
42 | msg.attach(part1)
43 | msg.attach(part2)
44 | text_fh.close()
45 | html_fh.close()
46 | filename_to_attach = filename_to_attach.replace("\\", "/")
47 |
48 | base_filename = basename(filename_to_attach)
49 | file_extension = os.path.splitext(base_filename)
50 |
51 | with open(filename_to_attach, 'rb') as attach_fh:
52 |
53 | file_to_attach = MIMEBase('application', file_extension)
54 | file_to_attach.set_payload(attach_fh.read())
55 | Encoders.encode_base64(file_to_attach)
56 | file_to_attach.add_header('Content-Disposition', 'attachment', filename=base_filename)
57 | msg.attach(file_to_attach)
58 | self.smtp_server.sendmail(from_user, to_user, msg.as_string())
59 | # Cleanup the file
60 | os.remove(filename_to_attach)
--------------------------------------------------------------------------------
/tableau_exceptions.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | class TableauException(Exception):
4 | def __str__(self):
5 | return "{} Exception: {}".format(self.__class__.__name__,self.msg)
6 |
7 | class NoMatchFoundException(TableauException):
8 | def __init__(self, msg):
9 | self.msg = msg
10 |
11 |
12 | class AlreadyExistsException(TableauException):
13 | def __init__(self, msg, existing_luid):
14 | self.msg = msg
15 | self.existing_luid = existing_luid
16 |
17 |
18 | # Raised when an action is attempted that requires being signed into that site
19 | class NotSignedInException(TableauException):
20 | def __init__(self, msg):
21 | self.msg = msg
22 |
23 |
24 | # Raise when something an option is passed that is not valid in the REST API (site_role, permissions name, etc)
25 | class InvalidOptionException(TableauException):
26 | def __init__(self, msg):
27 | self.msg = msg
28 |
29 |
30 | class RecoverableHTTPException(TableauException):
31 | def __init__(self, http_code, tableau_error_code, luid):
32 | self.http_code = http_code
33 | self.tableau_error_code = tableau_error_code
34 | self.luid = luid
35 |
36 | class PossibleInvalidPublishException(TableauException):
37 | def __init__(self, http_code, tableau_error_code, msg):
38 | self.http_code = http_code
39 | self.tableau_error_code = tableau_error_code
40 | self.msg = msg
41 |
42 | class MultipleMatchesFoundException(TableauException):
43 | def __init__(self, count):
44 | self.msg = 'Found {} matches for the request, something has the same name'.format(str(count))
45 |
46 |
47 | class NoResultsException(TableauException):
48 | def __init__(self, msg):
49 | self.msg = msg
50 |
--------------------------------------------------------------------------------
/tableau_http.py:
--------------------------------------------------------------------------------
1 | import urllib.request, urllib.error, urllib.parse
2 | import requests
3 | from .tableau_exceptions import *
4 | from typing import Union, Any, Optional, List, Dict, Tuple
5 |
6 | # Class for direct requests to the Tableau Sever only over HTTP
7 | class TableauHTTP:
8 | def __init__(self, tableau_server_url: str):
9 | self.tableau_server_url = tableau_server_url
10 | self.session = requests.Session()
11 |
12 | def get_trusted_ticket_for_user(self, username: str, site:str = 'default', client_ip: Optional[str] = None):
13 | trusted_url = self.tableau_server_url + "/trusted"
14 | self.session = requests.Session()
15 | post_data = "username={}".format(username)
16 | if site.lower() != 'default':
17 | post_data += "&target_site={}".format(site)
18 | if client_ip is not None:
19 | post_data += "&client_id={}".format(client_ip)
20 | response = self.session.post(trusted_url, data=post_data)
21 | ticket = response.content
22 | if ticket == '-1' or not ticket:
23 | raise NoResultsException('Ticket generation was not complete (returned "-1"), check Tableau Server trusted authorization configurations')
24 | else:
25 | return ticket
26 |
27 | # When establishing a trusted ticket session, all you want is a good response. You don't actually do anything with
28 | # the ticket or want to load the viz.
29 | # Fastest way is to add .png
30 | def redeem_trusted_ticket(self, view_to_redeem: str, trusted_ticket: str, site: str = 'default') -> requests.Response:
31 | trusted_view_url = "{}/trusted/{}".format(self.tableau_server_url, trusted_ticket)
32 | if site.lower() != 'default':
33 | trusted_view_url += "/t/{}/views/{}.png".format(site, view_to_redeem)
34 | else:
35 | trusted_view_url += "/views/{}.png".format(view_to_redeem)
36 | response = self.session.get(trusted_view_url)
37 | return response
38 |
39 |
40 | def create_trusted_ticket_session(self, view_to_redeem: str, username: str, site: str = 'default',
41 | client_ip: Optional[str] = None):
42 | ticket = self.get_trusted_ticket_for_user(username, site=site, client_ip=client_ip)
43 | self.redeem_trusted_ticket(view_to_redeem, ticket, site=site)
44 |
--------------------------------------------------------------------------------
/tableau_repository.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 | from .tableau_exceptions import *
4 | import psycopg2
5 | import psycopg2.extensions
6 | psycopg2.extensions.register_type(psycopg2.extensions.UNICODE)
7 | psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY)
8 | from typing import Union, Any, Optional, List, Dict, Tuple
9 |
10 | class TableauRepository:
11 | def __init__(self, tableau_server_url: str, repository_password: str, repository_username: str = 'readonly'):
12 | if repository_username not in ['tableau', 'readonly', 'tblwgadmin']:
13 | raise InvalidOptionException('Must use one of the three valid usernames')
14 |
15 | # Remove the http:// or https:// to log in to the repository. (Do we need things if this is SSL?)
16 | colon_slash_slash = tableau_server_url.find('://')
17 | if colon_slash_slash != -1:
18 | self.repository_server = tableau_server_url[colon_slash_slash+2:]
19 | else:
20 | self.repository_server = tableau_server_url
21 |
22 | self.repository_port = 8060
23 | self.repository_db = 'workgroup'
24 | # user 'tableau' does not have enough rights
25 | self.repository_user = repository_username
26 | self.repository_pw = repository_password
27 |
28 | self.db_conn = psycopg2.connect(host=self.repository_server, database=self.repository_db,
29 | user=self.repository_user, password=self.repository_pw,
30 | port=self.repository_port)
31 | self.db_conn.set_session(autocommit=True)
32 |
33 | def __del__(self):
34 | self.db_conn.close()
35 |
36 | # Base method for querying
37 | def query(self, sql: str, sql_parameter_list: Optional[List] = None):
38 |
39 | cur = self.db_conn.cursor()
40 | if sql_parameter_list is not None:
41 | cur.execute(sql, sql_parameter_list)
42 | else:
43 | cur.execute(sql)
44 | return cur
45 |
46 | def query_sessions(self, username: Optional[str] = None):
47 | # Trusted tickets sessions do not have anything in the 'data' column
48 | # The auth token is contained within the shared_wg_write column, stored as JSON
49 | sessions_sql = """
50 | SELECT
51 | sessions.session_id,
52 | sessions.data,
53 | sessions.updated_at,
54 | sessions.user_id,
55 | sessions.shared_wg_write,
56 | sessions.shared_vizql_write,
57 | system_users.name AS user_name,
58 | users.system_user_id
59 | FROM sessions
60 | JOIN users ON sessions.user_id = users.id
61 | JOIN system_users ON users.system_user_id = system_users.id
62 | """
63 | if username is not None:
64 | sessions_sql += "WHERE system_users.name = %s\n"
65 | sessions_sql += "ORDER BY sessions.updated_at DESC;"
66 |
67 | if username is not None:
68 | cur = self.query(sessions_sql, [username, ])
69 | else:
70 | cur = self.query(sessions_sql)
71 | return cur
72 |
73 | def query_subscriptions(self, schedule_name: Optional[str] = None, views_only: bool = True):
74 | subscriptions_sql = """
75 | SELECT
76 | s.id,
77 | s.subject,
78 | s.user_name,
79 | s.site_name,
80 | COALESCE(cv.repository_url, s.view_url) as view_url,
81 | sch.name,
82 | su.email
83 | FROM _subscriptions s
84 | LEFT JOIN _customized_views cv ON s.customized_view_id = cv.id
85 | JOIN _schedules sch ON sch.name = s.schedule_name
86 | JOIN system_users su ON su.name = s.user_name
87 | """
88 | if schedule_name is not None:
89 | subscriptions_sql += 'WHERE sch.name = %s\n'
90 | if views_only is True:
91 | subscriptions_sql += 'AND s.view_url IS NOT NULL -- Export command in tabcmd requires a View not a Workbook'
92 | else:
93 | if views_only is True:
94 | subscriptions_sql += 'WHERE s.view_url IS NOT NULL -- Export command in tabcmd requires a View not a Workbook'
95 |
96 | if schedule_name is not None:
97 | cur = self.query(subscriptions_sql, [schedule_name, ])
98 | else:
99 | cur = self.query(subscriptions_sql)
100 | return cur
101 |
102 | # Set extract refresh schedules
103 | def query_extract_schedules(self, schedule_name: Optional[str] = None):
104 | schedules_sql = """
105 | SELECT *
106 | FROM _schedules
107 | WHERE scheduled_action_type = 'Refresh Extracts'
108 | AND hidden = false
109 | """
110 | if schedule_name is not None:
111 | schedules_sql += 'AND name = %s\n'
112 | cur = self.query(schedules_sql, [schedule_name, ])
113 | else:
114 | cur = self.query(schedules_sql)
115 | return cur
116 |
117 | def get_extract_schedule_id_by_name(self, schedule_name: str):
118 | cur = self.query_extract_schedules(schedule_name=schedule_name)
119 | if cur.rowcount == 0:
120 | raise NoMatchFoundException('No schedule found with name "{}"'.format(schedule_name))
121 | sched_id = None
122 | # Should only be one row
123 | for row in cur:
124 | sched_id = row[0]
125 | return sched_id
126 |
127 | def query_sites(self, site_content_url: Optional[str] = None, site_pretty_name: Optional[str] = None):
128 | if site_content_url is None and site_pretty_name is None:
129 | raise InvalidOptionException('Must pass one of either the site_content_url or site_pretty_name')
130 |
131 | sites_sql = """
132 | SELECT *
133 | FROM _sites
134 | """
135 | if site_content_url is not None and site_pretty_name is None:
136 | sites_sql += 'WHERE url_namespace = %s\n'
137 | cur = self.query(sites_sql, [site_content_url, ])
138 | elif site_content_url is None and site_pretty_name is not None:
139 | sites_sql += 'WHERE name = %s\n'
140 | cur = self.query(sites_sql, [site_pretty_name, ])
141 | else:
142 | sites_sql += 'WHERE url_namesspace = %s AND name = %s\n'
143 | cur = self.query(sites_sql, [site_content_url, site_pretty_name])
144 |
145 | return cur
146 |
147 | def get_site_id_by_site_content_url(self, site_content_url: str):
148 | cur = self.query_sites(site_content_url=site_content_url)
149 | if cur.rowcount == 0:
150 | raise NoMatchFoundException('No site found with content url "{}"'.format(site_content_url))
151 | site_id = None
152 | # Should only be one row
153 | for row in cur:
154 | site_id = row[0]
155 | return site_id
156 |
157 | def get_site_id_by_site_pretty_name(self, site_pretty_name: str):
158 | cur = self.query_sites(site_pretty_name=site_pretty_name)
159 | if cur.rowcount == 0:
160 | raise NoMatchFoundException('No site found with pretty name "{}"'.format(site_pretty_name))
161 | site_id = None
162 | # Should only be one row
163 | for row in cur:
164 | site_id = row[0]
165 | return site_id
166 |
167 | def query_project_id_on_site_by_name(self, project_name: str, site_id: str):
168 | project_sql = """
169 | SELECT *
170 | FROM _projects
171 | WHERE project_name = %s
172 | AND site_id = %s
173 | """
174 | cur = self.query(project_sql, [project_name, site_id])
175 | if cur.rowcount == 0:
176 | raise NoMatchFoundException('No project named {} found on the site'.format(project_name))
177 | project_id = None
178 | for row in cur:
179 | project_id = row[0]
180 | return project_id
181 |
182 | def query_datasource_id_on_site_in_project(self, datasource_name: str, site_id: str, project_id: str):
183 | datasource_query = """
184 | SELECT id
185 | FROM _datasources
186 | WHERE name = %s
187 | AND site_id = %s
188 | AND project_id = %s
189 | """
190 | cur = self.query(datasource_query, [datasource_name, site_id, project_id])
191 | if cur.rowcount == 0:
192 | raise NoMatchFoundException('No data source found with name "{}"'.format(datasource_name))
193 | datasource_id = None
194 | for row in cur:
195 | datasource_id = row[0]
196 | return datasource_id
197 |
198 | def query_workbook_id_on_site_in_project(self, workbook_name: str, site_id: str, project_id: str):
199 | workbook_query = """
200 | SELECT id
201 | FROM _workbooks
202 | WHERE name = %s
203 | AND site_id = %s
204 | AND project_id = %s
205 | """
206 | cur = self.query(workbook_query, [workbook_name, site_id, project_id])
207 | if cur.rowcount == 0:
208 | raise NoMatchFoundException('No workbook found with name "{}"'.format(workbook_name))
209 | workbook_id = None
210 | for row in cur:
211 | workbook_id = row[0]
212 | return workbook_id
213 |
214 | def query_workbook_id_from_luid(self, workbook_luid: str):
215 | workbook_query = """
216 | SELECT id
217 | FROM workbooks
218 | WHERE luid = %s
219 | """
220 | cur = self.query(workbook_query, [workbook_luid, ])
221 | if cur.rowcount == 0:
222 | raise NoMatchFoundException('No workbook found with luid "{}"'.format(workbook_luid))
223 | workbook_id = None
224 | for row in cur:
225 | workbook_id = row[0]
226 | return workbook_id
227 |
228 | def query_site_id_from_workbook_luid(self, workbook_luid: str):
229 | workbook_query = """
230 | SELECT site_id
231 | FROM workbooks
232 | WHERE luid = %s
233 | """
234 | cur = self.query(workbook_query, [workbook_luid, ])
235 | if cur.rowcount == 0:
236 | raise NoMatchFoundException('No workbook found with luid "{}"'.format(workbook_luid))
237 | workbook_id = None
238 | for row in cur:
239 | workbook_id = row[0]
240 | return workbook_id
241 |
242 | def query_datasource_id_from_luid(self, datasource_luid: str):
243 | datasource_query = """
244 | SELECT id
245 | FROM datasources
246 | WHERE luid = %s
247 | """
248 | cur = self.query(datasource_query, [datasource_luid, ])
249 | if cur.rowcount == 0:
250 | raise NoMatchFoundException('No data source found with luid "{}"'.format(datasource_luid))
251 | datasource_id = None
252 | for row in cur:
253 | datasource_id = row[0]
254 | return datasource_id
255 |
256 | def query_site_id_from_datasource_luid(self, datasource_luid: str):
257 | datasource_query = """
258 | SELECT site_id
259 | FROM datasources
260 | WHERE luid = %s
261 | """
262 | cur = self.query(datasource_query, [datasource_luid, ])
263 | if cur.rowcount == 0:
264 | raise NoMatchFoundException('No data source found with luid "{}"'.format(datasource_luid))
265 | datasource_id = None
266 | for row in cur:
267 | datasource_id = row[0]
268 | return datasource_id
269 |
270 | # Need to add in some classes to find Custom View LUIDs
271 |
--------------------------------------------------------------------------------
/tableau_rest_api/__init__.py:
--------------------------------------------------------------------------------
1 | from .methods.alert import *
2 | from .methods.datasource import *
3 | from .methods.extract import *
4 | from .methods.favorites import *
5 | from .methods.flow import *
6 | from .methods.group import *
7 | from .methods.metadata import *
8 | from .methods.metrics import *
9 | from .methods.project import *
10 | from .methods.revision import *
11 | from .methods.schedule import *
12 | from .methods.site import *
13 | from .methods.subscription import *
14 | from .methods.user import *
15 | from .methods.webhooks import *
16 | from .methods.workbook import *
17 |
18 | #from .published_content import *
19 | #from .sort import *
20 | #from .url_filter import *
--------------------------------------------------------------------------------
/tableau_rest_api/methods/__init__.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 | from ._lookups import *
3 | from .alert import *
4 | from .datasource import *
5 | from .extract import *
6 | from .favorites import *
7 | from .flow import *
8 | from .group import *
9 | from .metadata import *
10 | from .metrics import *
11 | from .project import *
12 | from .revision import *
13 | from .schedule import *
14 | from .site import *
15 | from .subscription import *
16 | from .user import *
17 | from .webhooks import *
18 | from .workbook import *
--------------------------------------------------------------------------------
/tableau_rest_api/methods/alert.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | # First Alert Methods appear in API 3.2
4 | class AlertMethods():
5 | def __init__(self, rest_api_base: TableauRestApiBase):
6 | self.rest = rest_api_base
7 |
8 | #def __getattr__(self, attr):
9 | # return getattr(self.rest_api_base, attr)
10 |
11 | def query_data_driven_alerts(self) -> ET.Element:
12 | self.rest.start_log_block()
13 | alerts = self.rest.query_resource("dataAlerts")
14 | self.rest.end_log_block()
15 | return alerts
16 |
17 | def query_data_driven_alerts_for_view(self, view_luid: str) -> ET.Element:
18 | self.rest.start_log_block()
19 | alerts = self.rest.query_resource("dataAlerts?filter=viewId:eq:{}".format(view_luid))
20 | self.rest.end_log_block()
21 | return alerts
22 |
23 | def query_data_driven_alert_details(self, data_alert_luid: str) -> ET.Element:
24 | self.rest.start_log_block()
25 | alert_details = self.rest.query_resource("dataAlerts/{}".format(data_alert_luid))
26 | self.rest.end_log_block()
27 | return alert_details
28 |
29 | def delete_data_driven_alert(self, data_alert_luid: str):
30 | self.rest.start_log_block()
31 | url = self.rest.build_api_url("dataAlerts/{}".format(data_alert_luid))
32 | self.rest.send_delete_request(url)
33 | self.rest.end_log_block()
34 |
35 | def add_user_to_data_driven_alert(self, data_alert_luid: str, username_or_luid: str):
36 | self.rest.start_log_block()
37 | user_luid = self.rest.query_user_luid(username_or_luid)
38 |
39 | tsr = ET.Element("tsRequest")
40 | u = ET.Element("user")
41 | u.set("id", user_luid)
42 | tsr.append(u)
43 | url = self.rest.build_api_url('dataAlerts/{}/users'.format(data_alert_luid))
44 | self.rest.send_add_request(url, tsr)
45 | self.rest.end_log_block()
46 |
47 | def update_data_driven_alert(self, data_alert_luid: str, subject: Optional[str] = None,
48 | frequency: Optional[str] = None,
49 | owner_username_or_luid: Optional[str] = None) -> ET.Element:
50 | self.rest.start_log_block()
51 | tsr = ET.Element("tsRequest")
52 | d = ET.Element("dataAlert")
53 | if subject is not None:
54 | d.set("subject", subject)
55 |
56 | if frequency is not None:
57 | frequency = frequency.lower()
58 | allowed_frequency = ('once', 'frequently', 'hourly', 'daily', 'weekly')
59 | if frequency not in allowed_frequency:
60 | raise InvalidOptionException('frequency must be once, frequently, hourly, daily or weekly')
61 | d.set('frequency', frequency)
62 |
63 | if owner_username_or_luid is not None:
64 | owner_luid = self.rest.query_user_luid(owner_username_or_luid)
65 | o = ET.Element('owner')
66 | o.set("id", owner_luid)
67 | d.append(o)
68 |
69 | tsr.append(d)
70 | url = self.rest.build_api_url("dataAlerts/{}".format(data_alert_luid))
71 | response = self.rest.send_update_request(url, tsr)
72 | self.rest.end_log_block()
73 | return response
74 |
75 | def delete_user_from_data_driven_alert(self, data_alert_luid: str, username_or_luid: str):
76 | self.rest.start_log_block()
77 | user_luid = self.rest.query_user_luid(username_or_luid)
78 |
79 | url = self.rest.build_api_url('dataAlerts/{}/users/{}'.format(data_alert_luid, user_luid))
80 | self.rest.send_delete_request(url)
81 | self.rest.end_log_block()
82 |
83 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/datasource.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 | from ..published_content import Datasource
3 | from ...tableau_rest_xml import TableauRestXml
4 |
5 | class DatasourceMethods():
6 | def __init__(self, rest_api_base: TableauRestApiBase):
7 | self.rest = rest_api_base
8 |
9 | #def __getattr__(self, attr):
10 | # return getattr(self.rest_api_base, attr)
11 |
12 | def get_published_datasource_object(self, datasource_name_or_luid: str,
13 | project_name_or_luid: Optional[str] = None) -> Datasource:
14 | luid = self.rest.query_datasource_luid(datasource_name_or_luid, project_name_or_luid)
15 | ds_obj = Datasource(luid=luid, tableau_rest_api_obj=self.rest,
16 | default=False, logger_obj=self.rest.logger)
17 | return ds_obj
18 |
19 | def query_datasources(self, project_name_or_luid: Optional[str] = None, all_fields: Optional[bool] = True,
20 | filters: Optional[List[UrlFilter]] = None, sorts: Optional[List[Sort]] = None,
21 | fields: Optional[List[str]] = None) -> ET.Element:
22 |
23 | self.rest.start_log_block()
24 | if fields is None:
25 | if all_fields is True:
26 | fields = ['_all_']
27 |
28 | datasources = self.rest.query_resource('datasources', filters=filters, sorts=sorts, fields=fields)
29 |
30 | # If there is a project filter
31 | if project_name_or_luid is not None:
32 | project_luid = self.rest.query_project_luid(project_name_or_luid)
33 | dses_in_project = datasources.findall('.//t:project[@id="{}"]/..'.format(project_luid), TableauRestXml.ns_map)
34 | dses = ET.Element(self.rest.ns_prefix + 'datasources')
35 | for ds in dses_in_project:
36 | dses.append(ds)
37 | else:
38 | dses = datasources
39 |
40 | self.rest.end_log_block()
41 | return dses
42 |
43 | def query_datasources_json(self, all_fields: Optional[bool] = True, filters: Optional[List[UrlFilter]] = None,
44 | sorts: Optional[List[Sort]] = None, fields: Optional[List[str]] = None,
45 | page_number: Optional[int] = None) -> Dict:
46 |
47 | self.rest.start_log_block()
48 | if fields is None:
49 | if all_fields is True:
50 | fields = ['_all_']
51 |
52 | datasources = self.rest.query_resource_json('datasources', filters=filters, sorts=sorts, fields=fields,
53 | page_number=page_number)
54 |
55 | self.rest.end_log_block()
56 | return datasources
57 |
58 | # Tries to guess name or LUID, hope there is only one
59 | def query_datasource(self, ds_name_or_luid: str, proj_name_or_luid: Optional[str] = None) -> ET.Element:
60 | self.rest.start_log_block()
61 |
62 | ds_luid = self.rest.query_datasource_luid(ds_name_or_luid, proj_name_or_luid)
63 | ds = self.rest.query_resource("datasources/{}".format(ds_luid))
64 | self.rest.end_log_block()
65 | return ds
66 |
67 | # Filtering implemented in 2.2
68 | # query_workbook and query_workbook_luid can't be improved because filtering doesn't take a Project Name/LUID
69 | def query_datasource_content_url(self, datasource_name_or_luid: str,
70 | project_name_or_luid: Optional[str] = None) -> str:
71 | self.rest.start_log_block()
72 | ds = self.query_datasource(datasource_name_or_luid, project_name_or_luid)
73 | content_url = ds.get('contentUrl')
74 | self.rest.end_log_block()
75 | return content_url
76 |
77 | def delete_datasources(self, datasource_name_or_luid_s: Union[List[str], str]):
78 | self.rest.start_log_block()
79 | datasources = self.rest.to_list(datasource_name_or_luid_s)
80 | for datasource_name_or_luid in datasources:
81 | datasource_luid = self.rest.query_datasource_luid(datasource_name_or_luid, None)
82 | url = self.rest.build_api_url("datasources/{}".format(datasource_luid))
83 | self.rest.send_delete_request(url)
84 | self.rest.end_log_block()
85 |
86 | def update_datasource(self, datasource_name_or_luid: str, datasource_project_name_or_luid: Optional[str] = None,
87 | new_datasource_name: Optional[str] = None, new_project_name_or_luid: Optional[str] = None,
88 | new_owner_luid: Optional[str] = None, certification_status: Optional[bool] = None,
89 | certification_note: Optional[str] = None) -> ET.Element:
90 | self.rest.start_log_block()
91 | if certification_status not in [None, False, True]:
92 | raise InvalidOptionException('certification_status must be None, False, or True')
93 |
94 | datasource_luid = self.rest.query_datasource_luid(datasource_name_or_luid, datasource_project_name_or_luid)
95 |
96 | tsr = ET.Element("tsRequest")
97 | d = ET.Element("datasource")
98 | if new_datasource_name is not None:
99 | d.set('name', new_datasource_name)
100 | if certification_status is not None:
101 | d.set('isCertified', '{}'.format(str(certification_status).lower()))
102 | if certification_note is not None:
103 | d.set('certificationNote', certification_note)
104 | if new_project_name_or_luid is not None:
105 | new_project_luid = self.rest.query_project_luid(new_project_name_or_luid)
106 | p = ET.Element('project')
107 | p.set('id', new_project_luid)
108 | d.append(p)
109 | if new_owner_luid is not None:
110 | o = ET.Element('owner')
111 | o.set('id', new_owner_luid)
112 | d.append(o)
113 |
114 | tsr.append(d)
115 |
116 | url = self.rest.build_api_url("datasources/{}".format(datasource_luid))
117 | response = self.rest.send_update_request(url, tsr)
118 | self.rest.end_log_block()
119 | return response
120 |
121 | def update_datasource_connection_by_luid(self, datasource_luid: str, new_server_address: Optional[str] = None,
122 | new_server_port: Optional[str] = None,
123 | new_connection_username: Optional[str] = None,
124 | new_connection_password: Optional[str] = None) -> ET.Element:
125 | self.rest.start_log_block()
126 | tsr = self.rest.__build_connection_update_xml(new_server_address, new_server_port,
127 | new_connection_username,
128 | new_connection_password)
129 | url = self.rest.build_api_url("datasources/{}/connection".format(datasource_luid))
130 | response = self.rest.send_update_request(url, tsr)
131 | self.rest.end_log_block()
132 | return response
133 |
134 | # Do not include file extension. Without filename, only returns the response
135 | def download_datasource(self, ds_name_or_luid: str, filename_no_extension: str,
136 | proj_name_or_luid: Optional[str] = None,
137 | include_extract: Optional[bool] = True) -> str:
138 | self.rest.start_log_block()
139 |
140 | ds_luid = self.rest.query_datasource_luid(ds_name_or_luid, project_name_or_luid=proj_name_or_luid)
141 | try:
142 | if include_extract is False:
143 | url = self.rest.build_api_url("datasources/{}/content?includeExtract=False".format(ds_luid))
144 | else:
145 | url = self.rest.build_api_url("datasources/{}/content".format(ds_luid))
146 | ds = self.rest.send_binary_get_request(url)
147 | extension = None
148 | if self.rest._last_response_content_type.find('application/xml') != -1:
149 | extension = '.tds'
150 | elif self.rest._last_response_content_type.find('application/octet-stream') != -1:
151 | extension = '.tdsx'
152 | self.rest.log('Response type was {} so extension will be {}'.format(self.rest._last_response_content_type, extension))
153 | if extension is None:
154 | raise IOError('File extension could not be determined')
155 | except RecoverableHTTPException as e:
156 | self.rest.log("download_datasource resulted in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code))
157 | self.rest.end_log_block()
158 | raise
159 | except:
160 | self.rest.end_log_block()
161 | raise
162 | try:
163 |
164 | save_filename = filename_no_extension + extension
165 | save_file = open(save_filename, 'wb')
166 | save_file.write(ds)
167 | save_file.close()
168 |
169 | except IOError:
170 | self.rest.log("Error: File '{}' cannot be opened to save to".format(filename_no_extension + extension))
171 | raise
172 |
173 | self.rest.end_log_block()
174 | return save_filename
175 |
176 | def publish_datasource(self, ds_filename: str, ds_name: str, project_obj: Project,
177 | overwrite: bool = False, connection_username: Optional[str] = None,
178 | connection_password: Optional[str] = None, save_credentials: bool = True,
179 | oauth_flag: bool = False) -> str:
180 | project_luid = project_obj.luid
181 | xml = self.rest._publish_content('datasource', ds_filename, ds_name, project_luid, {"overwrite": overwrite},
182 | connection_username, connection_password, save_credentials, oauth_flag=oauth_flag)
183 | datasource = xml.findall('.//t:datasource', TableauRestXml.ns_map)
184 | return datasource[0].get('id')
185 |
186 | #
187 | # Tags
188 | #
189 |
190 | # Tags can be scalar string or list
191 | def add_tags_to_datasource(self, ds_name_or_luid: str, tag_s: Union[List[str], str],
192 | proj_name_or_luid: Optional[str] = None) -> ET.Element:
193 | self.rest.start_log_block()
194 |
195 | ds_luid = self.rest.query_workbook_luid(ds_name_or_luid, proj_name_or_luid)
196 | url = self.rest.build_api_url("datasources/{}/tags".format(ds_luid))
197 |
198 | tsr = ET.Element("tsRequest")
199 | ts = ET.Element("tags")
200 | tags = self.rest.to_list(tag_s)
201 | for tag in tags:
202 | t = ET.Element("tag")
203 | t.set("label", tag)
204 | ts.append(t)
205 | tsr.append(ts)
206 |
207 | tag_response = self.rest.send_update_request(url, tsr)
208 | self.rest.end_log_block()
209 | return tag_response
210 |
211 | def delete_tags_from_datasource(self, ds_name_or_luid: str, tag_s: Union[List[str], str],
212 | proj_name_or_luid: Optional[str] = None) -> int:
213 | self.rest.start_log_block()
214 | tags = self.rest.to_list(tag_s)
215 | ds_luid = self.rest.query_datasource_luid(ds_name_or_luid, proj_name_or_luid)
216 | deleted_count = 0
217 | for tag in tags:
218 | url = self.rest.build_api_url("datasources/{}/tags/{}".format(ds_luid, tag))
219 | deleted_count += self.rest.send_delete_request(url)
220 | self.rest.end_log_block()
221 | return deleted_count
222 |
223 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/extract.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 |
4 | class ExtractMethods():
5 | def __init__(self, rest_api_base: TableauRestApiBase):
6 | self.rest = rest_api_base
7 |
8 | #def __getattr__(self, attr):
9 | # return getattr(self.rest_api_base, attr)
10 |
11 | def get_extract_refresh_tasks(self) -> ET.Element:
12 | self.rest.start_log_block()
13 | extract_tasks = self.rest.query_resource('tasks/extractRefreshes')
14 | self.rest.end_log_block()
15 | return extract_tasks
16 |
17 | def get_extract_refresh_task(self, task_luid: str) -> ET.Element:
18 | self.rest.start_log_block()
19 | extract_task = self.rest.query_resource('tasks/extractRefreshes/{}'.format(task_luid))
20 | self.rest.start_log_block()
21 | return extract_task
22 |
23 | # From API 2.6. This gives back much more information
24 | def get_extract_refresh_tasks_on_schedule(self, schedule_name_or_luid: str) -> ET.Element:
25 | self.rest.start_log_block()
26 | schedule_luid = self.rest.query_schedule_luid(schedule_name_or_luid)
27 | tasks = self.get_extract_refresh_tasks()
28 | tasks_on_sched = tasks.findall('.//t:schedule[@id="{}"]/..'.format(schedule_luid), self.rest.ns_map)
29 | if len(tasks_on_sched) == 0:
30 | self.rest.end_log_block()
31 | raise NoMatchFoundException(
32 | "No extract refresh tasks found on schedule {}".format(schedule_name_or_luid))
33 | self.rest.end_log_block()
34 | return tasks_on_sched
35 |
36 | # From API 2.2, commented out for lack of being useful compared to _on_schedule above
37 | #def query_extract_refresh_tasks_in_a_schedule(self, schedule_name_or_luid: str) -> ET.Element:
38 | # self.start_log_block()
39 | # luid = self.query_schedule_luid(schedule_name_or_luid)
40 | # tasks = self.query_resource("schedules/{}/extracts".format(luid))
41 | # self.end_log_block()
42 | # return tasks
43 |
44 | def run_extract_refresh_task(self, task_luid:str) -> str:
45 | self.rest.start_log_block()
46 | tsr = ET.Element('tsRequest')
47 | url = self.rest.build_api_url('tasks/extractRefreshes/{}/runNow'.format(task_luid))
48 | response = self.rest.send_add_request(url, tsr)
49 | self.rest.end_log_block()
50 | return response.findall('.//t:job', self.rest.ns_map)[0].get("id")
51 |
52 | def run_all_extract_refreshes_for_schedule(self, schedule_name_or_luid: str):
53 | self.rest.start_log_block()
54 | extracts = self.query_extract_refresh_tasks_by_schedule(schedule_name_or_luid)
55 | for extract in extracts:
56 | self.run_extract_refresh_task(extract.get('id'))
57 | self.rest.end_log_block()
58 |
59 | def run_extract_refresh_for_workbook(self, wb_name_or_luid: str,
60 | proj_name_or_luid: Optional[str] = None) -> ET.Element:
61 | return self.update_workbook_now(wb_name_or_luid, proj_name_or_luid)
62 |
63 | # Use the specific refresh rather than the schedule task in 2.8
64 | def run_extract_refresh_for_datasource(self, ds_name_or_luid: str,
65 | proj_name_or_luid: Optional[str] = None) -> ET.Element:
66 | return self.update_datasource_now(ds_name_or_luid, proj_name_or_luid)
67 |
68 | # Checks status of AD sync process or extract
69 | def query_job(self, job_luid: str) -> ET.Element:
70 | self.rest.start_log_block()
71 | job = self.rest.query_resource("jobs/{}".format(job_luid))
72 | self.rest.end_log_block()
73 | return job
74 |
75 | def update_datasource_now(self, ds_name_or_luid: str,
76 | project_name_or_luid: Optional[str] = None) -> ET.Element:
77 |
78 | self.rest.start_log_block()
79 | ds_luid = self.rest.query_datasource_luid(ds_name_or_luid, project_name_or_luid=project_name_or_luid)
80 |
81 | # Has an empty request but is POST because it makes a
82 | tsr = ET.Element('tsRequest')
83 |
84 | url = self.rest.build_api_url('datasources/{}/refresh'.format(ds_luid))
85 | response = self.rest.send_add_request(url, tsr)
86 |
87 | self.rest.end_log_block()
88 | return response
89 |
90 | def update_workbook_now(self, wb_name_or_luid: str, project_name_or_luid: Optional[str] = None) -> ET.Element:
91 | self.rest.start_log_block()
92 | wb_luid = self.rest.query_workbook_luid(wb_name_or_luid, proj_name_or_luid=project_name_or_luid)
93 |
94 | # Has an empty request but is POST because it makes a
95 | tsr = ET.Element('tsRequest')
96 |
97 | url = self.rest.build_api_url('workbooks/{}/refresh'.format(wb_luid))
98 | response = self.rest.send_add_request(url, tsr)
99 |
100 | self.rest.end_log_block()
101 | return response
102 |
103 | def query_jobs(self, progress_filter: Optional[UrlFilter] = None, job_type_filter: Optional[UrlFilter] = None,
104 | created_at_filter: Optional[UrlFilter] = None, started_at_filter: Optional[UrlFilter] = None,
105 | ended_at_filter: Optional[UrlFilter] = None, title_filter: Optional[UrlFilter] = None,
106 | subtitle_filter: Optional[UrlFilter] = None,
107 | notes_filter: Optional[UrlFilter] = None) -> ET.Element:
108 | self.rest.start_log_block()
109 | filter_checks = {'progress': progress_filter, 'jobType': job_type_filter,
110 | 'createdAt': created_at_filter, 'title': title_filter,
111 | 'notes': notes_filter, 'endedAt': ended_at_filter,
112 | 'subtitle': subtitle_filter, 'startedAt': started_at_filter}
113 | filters = self.rest._check_filter_objects(filter_checks)
114 |
115 | jobs = self.rest.query_resource("jobs", filters=filters)
116 | self.rest.log('Found {} jobs'.format(str(len(jobs))))
117 | self.rest.end_log_block()
118 | return jobs
119 |
120 | def cancel_job(self, job_luid: str):
121 | self.rest.start_log_block()
122 | url = self.rest.build_api_url("jobs/{}".format(job_luid))
123 | self.rest.send_update_request(url, None)
124 | self.rest.end_log_block()
125 |
126 |
127 |
128 | class ExtractMethods35(ExtractMethods):
129 | def __init__(self, rest_api_base: TableauRestApiBase35):
130 | self.rest = rest_api_base
131 |
132 | def encrypt_extracts(self):
133 | self.rest.start_log_block()
134 | url = self.rest.build_api_url('encrypt-extracts')
135 | self.rest.send_post_request(url=url)
136 | self.rest.end_log_block()
137 |
138 | def decrypt_extracts(self):
139 | self.rest.start_log_block()
140 | url = self.rest.build_api_url('decrypt-extracts')
141 | self.rest.send_post_request(url=url)
142 | self.rest.end_log_block()
143 |
144 | def reencrypt_extracts(self):
145 | self.rest.start_log_block()
146 | url = self.rest.build_api_url('renecrypt-extracts')
147 | self.rest.send_post_request(url=url)
148 | self.rest.end_log_block()
149 |
150 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/favorites.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | import xml.etree.ElementTree as ET
4 |
5 | class FavoritesMethods():
6 | def __init__(self, rest_api_base: TableauRestApiBase):
7 | self.rest = rest_api_base
8 |
9 | #def __getattr__(self, attr):
10 | # return getattr(self.rest_api_base, attr)
11 |
12 | def add_workbook_to_user_favorites(self, favorite_name: str, wb_name_or_luid: str,
13 | username_or_luid: str, proj_name_or_luid: Optional[str] = None) -> ET.Element:
14 | self.rest.start_log_block()
15 | wb_luid = self.rest.query_workbook_luid(wb_name_or_luid, proj_name_or_luid)
16 | user_luid = self.rest.query_user_luid(username_or_luid)
17 |
18 | tsr = ET.Element('tsRequest')
19 | f = ET.Element('favorite')
20 | f.set('label', favorite_name)
21 | w = ET.Element('workbook')
22 | w.set('id', wb_luid)
23 | f.append(w)
24 | tsr.append(f)
25 |
26 | url = self.rest.build_api_url("favorites/{}".format(user_luid))
27 | update_response = self.rest.send_update_request(url, tsr)
28 | self.rest.end_log_block()
29 | return update_response
30 |
31 | def add_view_to_user_favorites(self, favorite_name: str, username_or_luid: str,
32 | view_name_or_luid: Optional[str]= None, view_content_url: Optional[str] = None,
33 | wb_name_or_luid: Optional[str] = None,
34 | proj_name_or_luid: Optional[str] = None) -> ET.Element:
35 | self.rest.start_log_block()
36 |
37 | view_luid = self.rest.query_workbook_view_luid(wb_name_or_luid, view_name_or_luid, view_content_url,
38 | proj_name_or_luid)
39 | self.rest.log('View luid found {}'.format(view_luid))
40 |
41 | user_luid = self.rest.query_user_luid(username_or_luid)
42 | tsr = ET.Element('tsRequest')
43 | f = ET.Element('favorite')
44 | f.set('label', favorite_name)
45 | v = ET.Element('view')
46 | v.set('id', view_luid)
47 | f.append(v)
48 | tsr.append(f)
49 |
50 | url = self.rest.build_api_url("favorites/{}".format(user_luid))
51 | update_response = self.rest.send_update_request(url, tsr)
52 | self.rest.end_log_block()
53 | return update_response
54 |
55 | def query_user_favorites(self, username_or_luid: str) -> ET.Element:
56 | self.rest.start_log_block()
57 | user_luid = self.rest.query_user_luid(username_or_luid)
58 | favorites = self.rest.query_resource("favorites/{}/".format(user_luid))
59 | self.rest.end_log_block()
60 | return favorites
61 |
62 | def query_user_favorites_json(self, username_or_luid: str, page_number: Optional[int] = None) -> str:
63 | self.rest.start_log_block()
64 | user_luid = self.rest.query_user_luid(username_or_luid)
65 | favorites = self.rest.query_resource_json("favorites/{}/".format(user_luid), page_number=page_number)
66 | self.rest.end_log_block()
67 | return favorites
68 |
69 | # Can take collection or luid_string
70 | def delete_workbooks_from_user_favorites(self, wb_name_or_luid_s: Union[List[str], str],
71 | username_or_luid: str):
72 | self.rest.start_log_block()
73 | wbs = self.rest.to_list(wb_name_or_luid_s)
74 | user_luid = self.rest.query_user_luid(username_or_luid)
75 | for wb in wbs:
76 | wb_luid = self.rest.query_workbook_luid(wb)
77 | url = self.rest.build_api_url("favorites/{}/workbooks/{}".format(user_luid, wb_luid))
78 | self.rest.send_delete_request(url)
79 | self.rest.end_log_block()
80 |
81 | def delete_views_from_user_favorites(self, view_name_or_luid_s: Union[List[str], str],
82 | username_or_luid: str, wb_name_or_luid: Optional[str] = None):
83 | self.rest.start_log_block()
84 | views = self.rest.to_list(view_name_or_luid_s)
85 |
86 | user_luid = self.rest.query_user_luid(username_or_luid)
87 | for view in views:
88 |
89 | view_luid = self.rest.query_workbook_view_luid(wb_name_or_luid, view)
90 | url = self.rest.build_api_url("favorites/{}/views/{}".format(user_luid, view_luid))
91 | self.rest.send_delete_request(url)
92 | self.rest.end_log_block()
93 |
94 | def add_datasource_to_user_favorites(self, favorite_name: str, ds_name_or_luid: str,
95 | username_or_luid: str, p_name_or_luid: Optional[str] = None):
96 | self.rest.start_log_block()
97 | user_luid = self.rest.query_user_luid(username_or_luid)
98 |
99 | datasource_luid = self.rest.query_datasource_luid(ds_name_or_luid, p_name_or_luid)
100 |
101 | tsr = ET.Element('tsRequest')
102 | f = ET.Element('favorite')
103 | f.set('label', favorite_name)
104 | d = ET.Element('datasource')
105 | d.set('id', datasource_luid)
106 | f.append(d)
107 | tsr.append(f)
108 |
109 | url = self.rest.build_api_url("favorites/{}".format(user_luid))
110 | self.rest.send_update_request(url, tsr)
111 |
112 | self.rest.end_log_block()
113 |
114 | def delete_datasources_from_user_favorites(self, ds_name_or_luid_s: Union[List[str], str],
115 | username_or_luid: str, p_name_or_luid: Optional[str] = None):
116 | self.rest.start_log_block()
117 | dses = self.rest.to_list(ds_name_or_luid_s)
118 | user_luid = self.rest.query_user_luid(username_or_luid)
119 | for ds in dses:
120 | ds_luid = self.rest.query_datasource_luid(ds, p_name_or_luid)
121 | url = self.rest.build_api_url("favorites/{}/datasources/{}".format(user_luid, ds_luid))
122 | self.rest.send_delete_request(url)
123 | self.rest.end_log_block()
124 |
125 | def add_project_to_user_favorites(self, favorite_name: str, proj_name_or_luid: str,
126 | username_or_luid: str) -> ET.Element:
127 | self.rest.start_log_block()
128 | proj_luid = self.rest.query_project_luid(proj_name_or_luid)
129 | user_luid = self.rest.query_user_luid(username_or_luid)
130 | tsr = ET.Element('tsRequest')
131 | f = ET.Element('favorite')
132 | f.set('label', favorite_name)
133 | w = ET.Element('project')
134 | w.set('id', proj_luid)
135 | f.append(w)
136 | tsr.append(f)
137 |
138 | url = self.rest.build_api_url("favorites/{}".format(user_luid))
139 | update_response = self.rest.send_update_request(url, tsr)
140 | self.rest.end_log_block()
141 | return update_response
142 |
143 | def delete_projects_from_user_favorites(self, proj_name_or_luid_s: Union[List[str], str],
144 | username_or_luid:str):
145 | self.rest.start_log_block()
146 | projs = self.rest.to_list(proj_name_or_luid_s)
147 | user_luid = self.rest.query_user_luid(username_or_luid)
148 | for proj in projs:
149 | proj_luid = self.rest.query_project_luid(proj)
150 | url = self.rest.build_api_url("favorites/{}/projects/{}".format(user_luid, proj_luid))
151 | self.rest.send_delete_request(url)
152 | self.rest.end_log_block()
153 |
154 |
155 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/flow.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | # First Flow Methods appear in API 3.3
4 | class FlowMethods33():
5 | def __init__(self, rest_api_base: TableauRestApiBase33):
6 | self.rest = rest_api_base
7 |
8 | #def __getattr__(self, attr):
9 | # return getattr(self.rest_api_base, attr)
10 |
11 | def query_flow_luid(self, flow_name: str, project_name_or_luid: Optional[str] = None) -> str:
12 | self.rest.start_log_block()
13 |
14 | flow_name_filter = UrlFilter33.create_name_filter(flow_name)
15 |
16 | flows = self.rest.query_flows_for_a_site(flow_name_filter=flow_name_filter,
17 | project_name_or_luid=project_name_or_luid)
18 | # There should only be one flow here if any found
19 | if len(flows) == 1:
20 | self.rest.end_log_block()
21 | return flows[0].get("id")
22 | else:
23 | self.rest.end_log_block()
24 | raise NoMatchFoundException("No {} found with name {}".format(flows, flow_name))
25 |
26 | def query_flows_for_a_site(self, project_name_or_luid: Optional[str] = None, all_fields: bool = True,
27 | updated_at_filter: Optional[UrlFilter] = None,
28 | created_at_filter: Optional[UrlFilter] = None,
29 | flow_name_filter: Optional[UrlFilter] = None,
30 | owner_name_filter: Optional[UrlFilter] = None, sorts: Optional[List[Sort]] = None,
31 | fields: Optional[List[str]] = None) -> ET.Element:
32 | self.rest.start_log_block()
33 | if fields is None:
34 | if all_fields is True:
35 | fields = ['_all_']
36 |
37 | # If create a ProjectName filter inherently if necessary
38 | project_name_filter = None
39 | if project_name_or_luid is not None:
40 | if not self.rest.is_luid(project_name_or_luid):
41 | project_name = project_name_or_luid
42 | else:
43 | project = self.rest.query_project_xml_object(project_name_or_luid)
44 | project_name = project.get('name')
45 | project_name_filter = UrlFilter33.create_project_name_equals_filter(project_name)
46 |
47 | filter_checks = {'updatedAt': updated_at_filter, 'createdAt': created_at_filter, 'name': flow_name_filter,
48 | 'ownerName': owner_name_filter, 'projectName': project_name_filter}
49 | filters = self.rest._check_filter_objects(filter_checks)
50 |
51 | flows = self.rest.query_resource('flows', filters=filters, sorts=sorts, fields=fields)
52 |
53 | self.rest.end_log_block()
54 | return flows
55 |
56 | def query_flows_for_a_user(self, username_or_luid: str, is_owner_flag: bool = False) -> ET.Element:
57 | self.rest.start_log_block()
58 | user_luid = self.rest.query_user_luid(username_or_luid)
59 | additional_url_params = ""
60 | if is_owner_flag is True:
61 | additional_url_params += "?ownedBy=true"
62 |
63 | flows = self.rest.query_resource('users/{}/flows{}'.format(user_luid, additional_url_params))
64 | self.rest.end_log_block()
65 | return flows
66 |
67 | def query_flow(self, flow_name_or_luid: str, project_name_or_luid: Optional[str] = None) -> ET.Element:
68 | self.rest.start_log_block()
69 | flow_luid = self.rest.query_flow_luid(flow_name_or_luid, project_name_or_luid=project_name_or_luid)
70 |
71 | flow = self.rest.query_resource('flows/{}'.format(flow_luid))
72 |
73 | self.rest.end_log_block()
74 | return flow
75 |
76 | def query_flow_connections(self, flow_name_or_luid: str,
77 | project_name_or_luid: Optional[str] = None) -> ET.Element:
78 | self.rest.start_log_block()
79 | flow_luid = self.rest.query_flow_luid(flow_name_or_luid, project_name_or_luid=project_name_or_luid)
80 | connections = self.rest.query_resource('flows/{}/connections'.format(flow_luid))
81 | self.rest.end_log_block()
82 | return connections
83 |
84 |
85 | def get_flow_run_tasks(self) -> ET.Element:
86 | self.rest.start_log_block()
87 | tasks = self.rest.query_resource('tasks/runFlow')
88 | self.rest.end_log_block()
89 | return tasks
90 |
91 | def get_flow_run_task(self, task_luid: str) -> ET.Element:
92 | self.rest.start_log_block()
93 | task = self.rest.query_resource('tasks/runFlow/{}'.format(task_luid))
94 | self.rest.end_log_block()
95 | return task
96 |
97 | def run_flow_now(self, flow_name_or_luid: str, flow_output_step_ids: Optional[List[str]] = None) -> str:
98 | self.rest.start_log_block()
99 | if self.rest.is_luid(flow_name_or_luid):
100 | flow_luid = flow_name_or_luid
101 | else:
102 | flow_luid = self.rest.query_flow_luid(flow_name_or_luid)
103 |
104 | additional_url_params = ""
105 |
106 | # Implement once documentation is back up and going
107 | if flow_output_step_ids is not None:
108 | pass
109 |
110 | tsr = ET.Element('tsRequest')
111 | url = self.rest.build_api_url("flows/{}/run{}".format(flow_luid, additional_url_params))
112 | job_luid = self.rest.send_add_request(url, tsr)
113 | self.rest.end_log_block()
114 | return job_luid
115 |
116 | def run_flow_task(self, task_luid: str) -> ET.Element:
117 | self.rest.start_log_block()
118 | url = self.rest.build_api_url('tasks/runFlow/{}/runNow'.format(task_luid))
119 | response = self.rest.send_post_request(url)
120 | self.rest.end_log_block()
121 | return response
122 |
123 |
124 | def update_flow(self, flow_name_or_luid: str, project_name_or_luid: Optional[str] = None,
125 | owner_username_or_luid: Optional[str] = None) -> ET.Element:
126 | self.rest.start_log_block()
127 | if project_name_or_luid is None and owner_username_or_luid is None:
128 | raise InvalidOptionException('Must include at least one change, either project or owner or both')
129 |
130 | if self.rest.is_luid(flow_name_or_luid):
131 | flow_luid = self.rest.query_flow_luid(flow_name_or_luid)
132 |
133 | tsr = ET.Element('tsRequest')
134 | f = ET.Element('flow')
135 | if project_name_or_luid is not None:
136 | proj_luid = self.rest.query_project_luid(project_name_or_luid)
137 | p = ET.Element('project')
138 | p.set('id', proj_luid)
139 | f.append(p)
140 |
141 | if owner_username_or_luid is not None:
142 | owner_luid = self.rest.query_user_luid(owner_username_or_luid)
143 |
144 | o = ET.Element('owner')
145 | o.set('id', owner_luid)
146 | f.append(o)
147 |
148 | tsr.append(f)
149 |
150 | url = self.rest.build_api_url('flows/{}'.format(flow_luid))
151 | response = self.rest.send_update_request(url, tsr)
152 |
153 | self.rest.end_log_block()
154 | return response
155 |
156 | def update_flow_connection(self, flow_luid: str, flow_connection_luid: str, server_address: Optional[str] = None,
157 | port: Optional[str] = None, connection_username: Optional[str] = None,
158 | connection_password: Optional[str] = None,
159 | embed_password: bool = False) -> ET.Element:
160 | self.rest.start_log_block()
161 |
162 | tsr = ET.Element('tsRequest')
163 | c = ET.Element('connection')
164 | updates_count = 0
165 | if server_address is not None:
166 | c.set('serverAddress', server_address)
167 | updates_count += 1
168 | if port is not None:
169 | c.set('port', port)
170 | updates_count += 1
171 | if connection_username is not None:
172 | c.set('userName', connection_username)
173 | updates_count += 1
174 | if connection_password is not None:
175 | c.set('password', connection_password)
176 | updates_count += 1
177 | if embed_password is True:
178 | c.set('embedPassword', 'true')
179 | updates_count += 1
180 |
181 | if updates_count == 0:
182 | raise InvalidOptionException('Must specify at least one element to update')
183 |
184 | tsr.append(c)
185 | url = self.rest.build_api_url('flows/{}/connections/{}'.format(flow_luid, flow_connection_luid))
186 | response = self.rest.send_update_request(url, tsr)
187 |
188 | self.rest.end_log_block()
189 | return response
190 |
191 | def delete_flow(self, flow_name_or_luid: str):
192 | self.rest.start_log_block()
193 | flow_luid = self.rest.query_flow_luid(flow_name_or_luid)
194 | url = self.rest.build_api_url("flows/{}".format(flow_luid))
195 | self.rest.send_delete_request(url)
196 | self.rest.end_log_block()
197 |
198 | def add_flow_task_to_schedule(self, flow_name_or_luid: str, schedule_name_or_luid: str) -> str:
199 | self.rest.start_log_block()
200 | flow_luid = self.rest.query_flow_luid(flow_name_or_luid)
201 | sched_luid = self.rest.query_schedule_luid(schedule_name_or_luid)
202 |
203 | tsr = ET.Element('tsRequest')
204 | t = ET.Element('task')
205 | fr = ET.Element('flowRun')
206 | f = ET.Element('flow')
207 | f.set('id', flow_luid)
208 | fr.append(f)
209 | t.append(fr)
210 | tsr.append(t)
211 |
212 | url = self.rest.build_api_url("schedules/{}/flows".format(sched_luid))
213 | response = self.rest.send_update_request(url, tsr)
214 |
215 | self.rest.end_log_block()
216 | return response
217 |
218 | # Do not include file extension, added automatically. Without filename, only returns the response
219 | # Use no_obj_return for save without opening and processing
220 | def download_flow(self, flow_name_or_luid: str, filename_no_extension: str,
221 | proj_name_or_luid: Optional[str] = None) -> str:
222 | self.rest.start_log_block()
223 | flow_luid = self.rest.query_workbook_luid(flow_name_or_luid, proj_name_or_luid)
224 | try:
225 |
226 | url = self.rest.build_api_url("flows/{}/content".format(flow_luid))
227 | flow = self.rest.send_binary_get_request(url)
228 | extension = None
229 | if self.rest._last_response_content_type.find('application/xml') != -1:
230 | extension = '.tfl'
231 | elif self.rest._last_response_content_type.find('application/octet-stream') != -1:
232 | extension = '.tflx'
233 | if extension is None:
234 | raise IOError('File extension could not be determined')
235 | self.rest.log(
236 | 'Response type was {} so extension will be {}'.format(self.rest._last_response_content_type, extension))
237 | except RecoverableHTTPException as e:
238 | self.rest.log("download_workbook resulted in HTTP error {}, Tableau Code {}".format(e.http_code, e.tableau_error_code))
239 | self.rest.end_log_block()
240 | raise
241 | except:
242 | self.rest.end_log_block()
243 | raise
244 | try:
245 |
246 | save_filename = filename_no_extension + extension
247 |
248 | save_file = open(save_filename, 'wb')
249 | save_file.write(flow)
250 | save_file.close()
251 |
252 | except IOError:
253 | self.rest.log("Error: File '{}' cannot be opened to save to".format(filename_no_extension + extension))
254 | raise
255 |
256 | self.rest.end_log_block()
257 | return save_filename
258 |
259 | def publish_flow(self, flow_filename: str, flow_name: str, project_obj: Project,
260 | overwrite: bool = False, connection_username: Optional[str] = None,
261 | connection_password: Optional[str] = None, save_credentials: bool = True,
262 | oauth_flag: bool = False, description: Optional[str] = None) -> str:
263 | project_luid = project_obj.luid
264 | xml = self.rest._publish_content(content_type='flow', content_filename=flow_filename, content_name=flow_name,
265 | project_luid=project_luid, url_params={"overwrite": overwrite},
266 | connection_username=connection_username, connection_password=connection_password,
267 | save_credentials=save_credentials, oauth_flag=oauth_flag, description=description)
268 | flow = xml.findall('.//t:flow', self.rest.ns_map)
269 | return flow[0].get('id')
270 |
271 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/group.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | class GroupMethods():
4 | def __init__(self, rest_api_base: TableauRestApiBase):
5 | self.rest: TableauRestApiBase = rest_api_base
6 |
7 | #def __getattr__(self, attr):
8 | # return getattr(self.rest_api_base, attr)
9 |
10 | def query_groups(self, filters: Optional[List[UrlFilter]] = None,
11 | sorts: Optional[List[Sort]] = None) -> ET.Element:
12 |
13 | self.rest.start_log_block()
14 | groups = self.rest.query_resource("groups", filters=filters, sorts=sorts)
15 | for group in groups:
16 | # Add to group-name : luid cache
17 | group_luid = group.get("id")
18 | group_name = group.get('name')
19 | self.rest.group_name_luid_cache[group_name] = group_luid
20 | self.rest.end_log_block()
21 | return groups
22 |
23 | # # No basic verb for querying a single group, so run a query_groups
24 |
25 | def query_groups_json(self, filters: Optional[List[UrlFilter]] = None,
26 | sorts: Optional[List[Sort]] = None, page_number: Optional[int] = None) -> Dict:
27 |
28 | self.rest.start_log_block()
29 | groups = self.rest.query_resource_json("groups", filters=filters, sorts=sorts, page_number=page_number)
30 | self.rest.end_log_block()
31 | return groups
32 |
33 | def query_group(self, group_name_or_luid: str) -> ET.Element:
34 | self.rest.start_log_block()
35 | group = self.rest.query_single_element_from_endpoint_with_filter('group', group_name_or_luid)
36 | # Add to group_name : luid cache
37 | group_luid = group.get("id")
38 | group_name = group.get('name')
39 | self.rest.group_name_luid_cache[group_name] = group_luid
40 |
41 | self.rest.end_log_block()
42 | return group
43 |
44 | # Returns the LUID of an existing group if one already exists
45 | def create_group(self, group_name: Optional[str] = None, direct_xml_request: Optional[ET.Element] = None) -> str:
46 | self.rest.start_log_block()
47 |
48 | if direct_xml_request is not None:
49 | tsr = direct_xml_request
50 | else:
51 | tsr = ET.Element("tsRequest")
52 | g = ET.Element("group")
53 | g.set("name", group_name)
54 | tsr.append(g)
55 |
56 | url = self.rest.build_api_url("groups")
57 | try:
58 | new_group = self.rest.send_add_request(url, tsr)
59 | self.rest.end_log_block()
60 | return new_group.findall('.//t:group', self.rest.ns_map)[0].get("id")
61 | # If the name already exists, a HTTP 409 throws, so just find and return the existing LUID
62 | except RecoverableHTTPException as e:
63 | if e.http_code == 409:
64 | self.rest.log('Group named {} already exists, finding and returning the LUID'.format(group_name))
65 | self.rest.end_log_block()
66 | return self.rest.query_group_luid(group_name)
67 |
68 | # Creating a synced ad group is completely different, use this method
69 | # The luid is only available in the Response header if bg sync. Nothing else is passed this way -- how to expose?
70 | def create_group_from_ad_group(self, ad_group_name: str, ad_domain_name: str,
71 | default_site_role: Optional[str] = 'Unlicensed',
72 | sync_as_background: bool = True) -> str:
73 | self.rest.start_log_block()
74 | if default_site_role not in self.rest._site_roles:
75 | raise InvalidOptionException('"{}" is not an acceptable site role'.format(default_site_role))
76 |
77 | tsr = ET.Element("tsRequest")
78 | g = ET.Element("group")
79 | g.set("name", ad_group_name)
80 | i = ET.Element("import")
81 | i.set("source", "ActiveDirectory")
82 | i.set("domainName", ad_domain_name)
83 | i.set("siteRole", default_site_role)
84 | g.append(i)
85 | tsr.append(g)
86 |
87 | url = self.rest.build_api_url("groups/?asJob={}".format(str(sync_as_background).lower()))
88 | self.rest.log(url)
89 | response = self.rest.send_add_request(url, tsr)
90 | # Response is different from immediate to background update. job ID lets you track progress on background
91 | if sync_as_background is True:
92 | job = response.findall('.//t:job', self.rest.ns_map)
93 | self.rest.end_log_block()
94 | return job[0].get('id')
95 | if sync_as_background is False:
96 | self.rest.end_log_block()
97 | group = response.findall('.//t:group', self.rest.ns_map)
98 | return group[0].get('id')
99 |
100 | # Take a single user_luid string or a collection of luid_strings
101 | def add_users_to_group(self, username_or_luid_s: Union[List[str], str], group_name_or_luid: str) -> ET.Element:
102 | self.rest.start_log_block()
103 | group_luid = self.rest.query_group_luid(group_name_or_luid)
104 |
105 | users = self.rest.to_list(username_or_luid_s)
106 | for user in users:
107 | user_luid = self.rest.query_user_luid(user)
108 |
109 | tsr = ET.Element("tsRequest")
110 | u = ET.Element("user")
111 | u.set("id", user_luid)
112 | tsr.append(u)
113 |
114 | url = self.rest.build_api_url("groups/{}/users/".format(group_luid))
115 | try:
116 | self.rest.log("Adding username ID {} to group ID {}".format(user_luid, group_luid))
117 | result = self.rest.send_add_request(url, tsr)
118 | return result
119 | except RecoverableHTTPException as e:
120 | self.rest.log("Recoverable HTTP exception {} with Tableau Error Code {}, skipping".format(str(e.http_code), e.tableau_error_code))
121 | self.rest.end_log_block()
122 |
123 | def query_users_in_group(self, group_name_or_luid: str) -> ET.Element:
124 | self.rest.start_log_block()
125 | luid = self.rest.query_group_luid(group_name_or_luid)
126 | users = self.rest.query_resource("groups/{}/users".format(luid))
127 | self.rest.end_log_block()
128 | return users
129 |
130 | # Local Authentication update group
131 | def update_group(self, name_or_luid: str, new_group_name: str) -> ET.Element:
132 | self.rest.start_log_block()
133 | group_luid = self.rest.query_group_luid(name_or_luid)
134 |
135 | tsr = ET.Element("tsRequest")
136 | g = ET.Element("group")
137 | g.set("name", new_group_name)
138 | tsr.append(g)
139 |
140 | url = self.rest.build_api_url("groups/{}".format(group_luid))
141 | response = self.rest.send_update_request(url, tsr)
142 | self.rest.end_log_block()
143 | return response
144 |
145 | # AD group sync. Must specify the domain and the default site role for imported users
146 | def sync_ad_group(self, group_name_or_luid: str, ad_group_name: str, ad_domain: str, default_site_role: str,
147 | sync_as_background: bool = True) -> str:
148 | self.rest.start_log_block()
149 | if sync_as_background not in [True, False]:
150 | error = "'{}' passed for sync_as_background. Use True or False".format(str(sync_as_background).lower())
151 | raise InvalidOptionException(error)
152 |
153 | # Check that the group exists
154 | self.query_group(group_name_or_luid)
155 | tsr = ET.Element('tsRequest')
156 | g = ET.Element('group')
157 | g.set('name', ad_group_name)
158 | i = ET.Element('import')
159 | i.set('source', 'ActiveDirectory')
160 | i.set('domainName', ad_domain)
161 | i.set('siteRole', default_site_role)
162 | g.append(i)
163 | tsr.append(g)
164 |
165 | group_luid = self.rest.query_group_luid(group_name_or_luid)
166 | url = self.rest.build_api_url(
167 | "groups/{}".format(group_luid) + "?asJob={}".format(str(sync_as_background)).lower())
168 | response = self.rest.send_update_request(url, tsr)
169 | # Response is different from immediate to background update. job ID lets you track progress on background
170 | if sync_as_background is True:
171 | job = response.findall('.//t:job', self.rest.ns_map)
172 | self.rest.end_log_block()
173 | return job[0].get('id')
174 | if sync_as_background is False:
175 | group = response.findall('.//t:group', self.rest.ns_map)
176 | self.rest.end_log_block()
177 | return group[0].get('id')
178 |
179 | def delete_groups(self, group_name_or_luid_s: Union[List[str], str]):
180 | self.rest.start_log_block()
181 | groups = self.rest.to_list(group_name_or_luid_s)
182 | for group_name_or_luid in groups:
183 | if group_name_or_luid == 'All Users':
184 | self.rest.log('Cannot delete All Users group, skipping')
185 | continue
186 | if self.rest.is_luid(group_name_or_luid):
187 | group_luid = group_name_or_luid
188 | else:
189 | group_luid = self.rest.query_group_luid(group_name_or_luid)
190 | url = self.rest.build_api_url("groups/{}".format(group_luid))
191 | self.rest.send_delete_request(url)
192 | self.rest.end_log_block()
193 |
194 | def remove_users_from_group(self, username_or_luid_s: Union[List[str], str], group_name_or_luid: str):
195 | self.rest.start_log_block()
196 | group_name = ""
197 | if self.rest.is_luid(group_name_or_luid):
198 | group_luid = group_name_or_luid
199 | else:
200 | group_name = group_name_or_luid
201 | group_luid = self.rest.query_group_name(group_name_or_luid)
202 | users = self.rest.to_list(username_or_luid_s)
203 | for user in users:
204 | username = ""
205 | if self.rest.is_luid(user):
206 | user_luid = user
207 | else:
208 | username = user
209 | user_luid = self.rest.query_user_luid(user)
210 | url = self.rest.build_api_url("groups/{}/users/{}".format(group_luid, user_luid))
211 | self.rest.log('Removing user {}, id {} from group {}, id {}'.format(username, user_luid, group_name, group_luid))
212 | self.rest.send_delete_request(url)
213 | self.rest.end_log_block()
214 |
215 |
216 | class GroupMethods37(GroupMethods):
217 | def __init__(self, rest_api_base: TableauRestApiBase36):
218 | self.rest = rest_api_base
219 |
220 | def get_groups_for_a_user(self, username_or_luid: str) -> ET.Element:
221 | self.rest.start_log_block()
222 | luid = self.rest.query_user_luid(username_or_luid)
223 | users = self.rest.query_resource("users/{}/groups".format(luid))
224 | self.rest.end_log_block()
225 | return users
--------------------------------------------------------------------------------
/tableau_rest_api/methods/metadata.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 | from ..permissions import DatabasePermissions35, TablePermissions35
3 | import json
4 |
5 |
6 | # First Metadata Methods appear in API 3.5
7 | class MetadataMethods35():
8 | def __init__(self, rest_api_base: TableauRestApiBase35):
9 | self.rest = rest_api_base
10 |
11 | #def __getattr__(self, attr):
12 | # return getattr(self.rest_api_base, attr)
13 |
14 | def query_databases(self) -> ET.Element:
15 | self.rest.start_log_block()
16 | response = self.rest.query_resource("databases")
17 | self.rest.end_log_block()
18 | return response
19 |
20 | def query_database(self, database_name_or_luid: str) -> ET.Element:
21 | self.rest.start_log_block()
22 | # Implement the search mechanism eventually using XPath similar to old workbooks / datasources lookup
23 | response = self.rest.query_single_element_from_endpoint("database", name_or_luid=database_name_or_luid)
24 | self.rest.end_log_block()
25 | return response
26 |
27 | def update_database(self, database_name_or_luid: str, certification_status: Optional[bool] = None,
28 | certification_note: Optional[str] = None, description: Optional[str] = None,
29 | contact_username_or_luid: Optional[str] = None) -> ET.Element:
30 | self.rest.start_log_block()
31 | tsr = ET.Element('tsRequest')
32 | d = ET.Element('database')
33 | if certification_status is not None:
34 | d.set('isCertified', str(certification_status).lower())
35 | if certification_note is not None:
36 | d.set('certificationNote', certification_note)
37 | if description is not None:
38 | d.set('description', description)
39 |
40 | if contact_username_or_luid is not None:
41 | user_luid = self.rest.rest_api_base.query_user_luid(username=contact_username_or_luid)
42 | c = ET.Element('contact')
43 | c.set('id', user_luid)
44 | d.append(c)
45 | tsr.append(d)
46 | database_luid = self.rest.query_database_luid(database_name=database_name_or_luid)
47 | url = self.rest.build_api_url("databases/{}".format(database_luid))
48 | response = self.rest.send_update_request(url=url, request=tsr)
49 | self.rest.end_log_block()
50 | return response
51 |
52 | def remove_database(self, database_name_or_luid: str):
53 | self.rest.start_log_block()
54 | database_luid = self.rest.query_database_luid(database_name=database_name_or_luid)
55 | url = self.rest.build_api_url("databases/{}".format(database_luid))
56 | self.rest.rest_api_base.send_delete_request(url)
57 | self.rest.end_log_block()
58 |
59 | def query_tables(self) -> ET.Element:
60 | self.rest.start_log_block()
61 | response = self.rest.query_resource("tables")
62 | self.rest.end_log_block()
63 | return response
64 |
65 | def query_table(self, table_name_or_luid: str) -> ET.Element:
66 | self.rest.start_log_block()
67 | # Implement the search mechanism eventually using XPath similar to old workbooks / datasources lookup
68 | response = self.rest.query_single_element_from_endpoint("table", name_or_luid=table_name_or_luid)
69 | self.rest.end_log_block()
70 | return response
71 |
72 | def update_table(self, table_name_or_luid: str, certification_status: Optional[bool] = None,
73 | certification_note: Optional[str] = None, description: Optional[str] = None,
74 | contact_username_or_luid: Optional[str] = None) -> ET.Element:
75 | self.rest.start_log_block()
76 | tsr = ET.Element('tsRequest')
77 | t = ET.Element('table')
78 | if certification_status is not None:
79 | t.set('isCertified', str(certification_status).lower())
80 | if certification_note is not None:
81 | t.set('certificationNote', certification_note)
82 | if description is not None:
83 | t.set('description', description)
84 |
85 | if contact_username_or_luid is not None:
86 | user_luid = self.rest.rest_api_base.query_user_luid(username=contact_username_or_luid)
87 | c = ET.Element('contact')
88 | c.set('id', user_luid)
89 | t.append(c)
90 | tsr.append(t)
91 | table_luid = self.rest.query_database_luid(database_name=table_name_or_luid)
92 | url = self.rest.build_api_url("tables/{}".format(table_luid))
93 | response = self.rest.send_update_request(url=url, request=tsr)
94 | self.rest.end_log_block()
95 | return response
96 |
97 | def remove_table(self, table_name_or_luid: str):
98 | self.rest.start_log_block()
99 | table_luid = self.rest.query_table_luid(table_name=table_name_or_luid)
100 | url = self.rest.build_api_url("tables/{}".format(table_luid))
101 | self.rest.rest_api_base.send_delete_request(url)
102 | self.rest.end_log_block()
103 |
104 | def query_columns_in_a_table(self, table_name_or_luid: str) -> ET.Element:
105 | self.rest.start_log_block()
106 | table_luid = self.rest.query_table_luid(table_name_or_luid)
107 | response = self.rest.query_resource("tables/{}/columns".format(table_luid))
108 | self.rest.end_log_block()
109 | return response
110 |
111 | def update_column(self, table_name_or_luid: str, column_name_or_luid: str,
112 | description: Optional[str] = None) -> ET.Element:
113 | self.rest.start_log_block()
114 | tsr = ET.Element('tsRequest')
115 | c = ET.Element('column')
116 | if description is not None:
117 | c.set('description', description)
118 | tsr.append(c)
119 | table_luid = self.rest.query_table_luid(table_name=table_name_or_luid)
120 | column_luid = self.rest.query_column_luid(database_name=column_name_or_luid)
121 | url = self.rest.build_api_url("tables/{}/columns/{}".format(table_luid, column_luid))
122 | response = self.rest.send_update_request(url=url, request=tsr)
123 | self.rest.end_log_block()
124 | return response
125 |
126 | def remove_column(self, table_name_or_luid: str, column_name_or_luid: str):
127 | self.rest.start_log_block()
128 | table_luid = self.rest.query_table_luid(table_name=table_name_or_luid)
129 | column_luid = self.rest.query_column_luid(database_name=column_name_or_luid)
130 | url = self.rest.build_api_url("tables/{}/columns/{}".format(table_luid, column_luid))
131 | self.rest.send_delete_request(url)
132 | self.rest.end_log_block()
133 |
134 | # There does not appear to be a plural Data Quality Warnings endpoint
135 |
136 | def add_data_quality_warning(self, content_type: str, content_luid: str) -> ET.Element:
137 | self.rest.start_log_block()
138 | content_type = content_type.lower()
139 | if content_type not in ['database', 'table', 'datasource', 'flow']:
140 | raise InvalidOptionException("content_type must be one of: 'database', 'table', 'datasource', 'flow'")
141 | url = self.rest.build_api_url("dataQualityWarnings/{}/{}".format(content_type, content_luid))
142 | response = self.rest.send_post_request(url)
143 | self.rest.end_log_block()
144 | return response
145 |
146 | def query_data_quality_warning_by_id(self, data_quality_warning_luid: str) -> ET.Element:
147 | self.rest.start_log_block()
148 | url = self.rest.build_api_url("dataQualityWarnings/{}".format(data_quality_warning_luid))
149 | response = self.rest.query_resource(url)
150 | self.rest.end_log_block()
151 | return response
152 |
153 | def query_data_quality_warning_by_asset(self, content_type: str, content_luid: str) -> ET.Element:
154 | self.rest.start_log_block()
155 | content_type = content_type.lower()
156 | if content_type not in ['database', 'table', 'datasource', 'flow']:
157 | raise InvalidOptionException("content_type must be one of: 'database', 'table', 'datasource', 'flow'")
158 | url = self.rest.build_api_url("dataQualityWarnings/{}/{}".format(content_type, content_luid))
159 | response = self.rest.query_resource(url)
160 | self.rest.end_log_block()
161 | return response
162 |
163 | def update_data_quality_warning(self, data_quality_warning_luid: str, type: Optional[str] = None,
164 | is_active: Optional[bool] = None, message: Optional[str] = None) -> ET.Element:
165 | self.rest.start_log_block()
166 | tsr = ET.Element('tsRequest')
167 | d = ET.Element('dataQualityWarning')
168 | if type is not None:
169 | if type not in ['Deprecated', 'Warning', 'Stale data', 'Under maintenance']:
170 | raise InvalidOptionException("The following are the allowed types: 'Deprecated', 'Warning', 'Stale data', 'Under maintenance'")
171 | d.set('type', type)
172 | if is_active is not None:
173 | d.set('isActive', str(is_active).lower())
174 | if message is not None:
175 | d.set('message', message)
176 | tsr.append(d)
177 |
178 | url = self.rest.build_api_url("dataQualityWarnings/{}".format(data_quality_warning_luid))
179 | response = self.rest.send_update_request(url=url, request=tsr)
180 | self.rest.end_log_block()
181 | return response
182 |
183 | def delete_data_quality_warning(self, data_quality_warning_luid: str):
184 | self.rest.start_log_block()
185 | url = self.rest.build_api_url("dataQualityWarnings/{}".format(data_quality_warning_luid))
186 | self.rest.send_delete_request(url)
187 | self.rest.end_log_block()
188 |
189 | # This is called "Delete Data Quality Warning by Content" in the reference, but Query is called "by Asset"
190 | # So this method is implemented with a parallel naming (By Asset is better IMHO)
191 | def delete_data_quality_warning_by_asset(self, content_type: str, content_luid: str):
192 | self.rest.start_log_block()
193 | content_type = content_type.lower()
194 | if content_type not in ['database', 'table', 'datasource', 'flow']:
195 | raise InvalidOptionException("content_type must be one of: 'database', 'table', 'datasource', 'flow'")
196 | url = self.rest.build_api_url("dataQualityWarnings/{}/{}".format(content_type, content_luid))
197 | self.rest.send_delete_request(url)
198 | self.rest.end_log_block()
199 |
200 | # This is the naming in the reference, but Query is called "by asset" which is better IMHO
201 | def delete_data_quality_warning_by_content(self, content_type: str, content_luid: str):
202 | self.rest.delete_data_quality_warning_by_asset(content_type=content_type, content_luid=content_luid)
203 |
204 | # Described here https://help.tableau.com/current/api/metadata_api/en-us/index.html
205 | # Uses json.loads() to build the JSON object to send
206 | # Need to implement POST on the RestJsonRequest object to be able to do this
207 | def graphql(self, graphql_query: str) -> Dict:
208 | self.rest.start_log_block()
209 | graphql_json = {"query": graphql_query}
210 | url = self.rest.rest_api_base.build_api_url("metadata")
211 | try:
212 | response = self.rest.rest_api_base.send_add_request_json(url, graphql_json)
213 | self.rest.end_log_block()
214 | return response
215 | except RecoverableHTTPException as e:
216 | if e.tableau_error_code == '404003':
217 | self.rest.end_log_block()
218 | raise InvalidOptionException("The metadata API is not turned on for this server at this time")
219 |
220 |
221 |
222 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/metrics.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | # First Metrics Methods appear in API 3.9
4 | class MetricsMethods39():
5 | def __init__(self, rest_api_base: TableauRestApiBase38):
6 | self.rest = rest_api_base
7 |
8 |
9 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/revision.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | class RevisionMethods():
4 | def __init__(self, rest_api_base: TableauRestApiBase):
5 | self.rest = rest_api_base
6 |
7 | #def __getattr__(self, attr):
8 | # return getattr(self.rest_api_base, attr)
9 |
10 | def get_workbook_revisions(self, workbook_name_or_luid: str,
11 | project_name_or_luid: Optional[str] = None) -> ET.Element:
12 | self.rest.start_log_block()
13 | wb_luid = self.rest.query_workbook_luid(workbook_name_or_luid, project_name_or_luid)
14 | wb_revisions = self.rest.query_resource('workbooks/{}/revisions'.format(wb_luid))
15 | self.rest.end_log_block()
16 | return wb_revisions
17 |
18 | def get_datasource_revisions(self, datasource_name_or_luid: str,
19 | project_name_or_luid: Optional[str] = None) -> ET.Element:
20 | self.rest.start_log_block()
21 | ds_luid = self.rest.query_datasource_luid(datasource_name_or_luid, project_name_or_luid)
22 | wb_revisions = self.rest.query_resource('datasources/{}/revisions'.format(ds_luid))
23 | self.rest.end_log_block()
24 | return wb_revisions
25 |
26 | def remove_datasource_revision(self, datasource_name_or_luid: str, revision_number: int,
27 | project_name_or_luid: Optional[str] = None):
28 | self.rest.start_log_block()
29 | ds_luid = self.rest.query_datasource_luid(datasource_name_or_luid, project_name_or_luid)
30 | url = self.rest.build_api_url("datasources/{}/revisions/{}".format(ds_luid, str(revision_number)))
31 | self.rest.send_delete_request(url)
32 | self.rest.end_log_block()
33 |
34 | def remove_workbook_revision(self, wb_name_or_luid: str, revision_number: int,
35 | project_name_or_luid: Optional[str] = None):
36 |
37 | self.rest.start_log_block()
38 | wb_luid = self.rest.query_workbook_luid(wb_name_or_luid, project_name_or_luid)
39 | url = self.rest.build_api_url("workbooks/{}/revisions/{}".format(wb_luid, str(revision_number)))
40 | self.rest.send_delete_request(url)
41 | self.rest.end_log_block()
42 |
43 | # Do not include file extension. Without filename, only returns the response
44 | # Rewrite to have a Download and a Save, with one giving object in memory
45 | def download_datasource_revision(self, ds_name_or_luid: str, revision_number: int, filename_no_extension: str,
46 | proj_name_or_luid: Optional[str] = None,
47 | include_extract: bool = True) -> str:
48 | self.rest.start_log_block()
49 |
50 | ds_luid = self.rest.query_datasource_luid(ds_name_or_luid, proj_name_or_luid)
51 | try:
52 |
53 | if include_extract is False:
54 | url = self.rest.build_api_url("datasources/{}/revisions/{}/content?includeExtract=False".format(ds_luid,
55 | str(revision_number)))
56 | else:
57 | url = self.rest.build_api_url(
58 | "datasources/{}/revisions/{}/content".format(ds_luid, str(revision_number)))
59 | ds = self.rest.send_binary_get_request(url)
60 | extension = None
61 | if self.rest._last_response_content_type.find('application/xml') != -1:
62 | extension = '.tds'
63 | elif self.rest._last_response_content_type.find('application/octet-stream') != -1:
64 | extension = '.tdsx'
65 | if extension is None:
66 | raise IOError('File extension could not be determined')
67 | except RecoverableHTTPException as e:
68 | self.rest.log("download_datasource resulted in HTTP error {}, Tableau Code {}".format(e.http_code,
69 | e.tableau_error_code))
70 | self.rest.end_log_block()
71 | raise
72 | except:
73 | self.rest.end_log_block()
74 | raise
75 | try:
76 | if filename_no_extension is None:
77 | save_filename = 'temp_ds' + extension
78 | else:
79 | save_filename = filename_no_extension + extension
80 | save_file = open(save_filename, 'wb')
81 | save_file.write(ds)
82 | save_file.close()
83 | self.rest.end_log_block()
84 | return save_filename
85 | except IOError:
86 | self.rest.log("Error: File '{}' cannot be opened to save to".format(filename_no_extension + extension))
87 | self.rest.end_log_block()
88 | raise
89 |
90 | # Do not include file extension, added automatically. Without filename, only returns the response
91 |
92 | def download_workbook_revision(self, wb_name_or_luid: str, revision_number: int, filename_no_extension: str,
93 | proj_name_or_luid: Optional[str] = None,
94 | include_extract: bool = True) -> str:
95 | self.rest.start_log_block()
96 |
97 | wb_luid = self.rest.query_workbook_luid(wb_name_or_luid, proj_name_or_luid)
98 | try:
99 | if include_extract is False:
100 | url = self.rest.build_api_url("workbooks/{}/revisions/{}/content?includeExtract=False".format(wb_luid,
101 | str(revision_number)))
102 | else:
103 | url = self.rest.build_api_url("workbooks/{}/revisions/{}/content".format(wb_luid, str(revision_number)))
104 | wb = self.rest.send_binary_get_request(url)
105 | extension = None
106 | if self.rest._last_response_content_type.find('application/xml') != -1:
107 | extension = '.twb'
108 | elif self.rest._last_response_content_type.find('application/octet-stream') != -1:
109 | extension = '.twbx'
110 | if extension is None:
111 | raise IOError('File extension could not be determined')
112 | except RecoverableHTTPException as e:
113 | self.rest.log("download_workbook resulted in HTTP error {}, Tableau Code {}".format(e.http_code,
114 | e.tableau_error_code))
115 | self.rest.end_log_block()
116 | raise
117 | except:
118 | self.rest.end_log_block()
119 | raise
120 | try:
121 | if filename_no_extension is None:
122 | save_filename = 'temp_wb' + extension
123 | else:
124 | save_filename = filename_no_extension + extension
125 |
126 | save_file = open(save_filename, 'wb')
127 | save_file.write(wb)
128 | save_file.close()
129 | self.rest.end_log_block()
130 | return save_filename
131 |
132 | except IOError:
133 | self.rest.log("Error: File '{}' cannot be opened to save to".format(filename_no_extension + extension))
134 | self.rest.end_log_block()
135 | raise
136 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/site.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 |
4 | class SiteMethods():
5 | def __init__(self, rest_api_base: TableauRestApiBase):
6 | self.rest = rest_api_base
7 |
8 | #def __getattr__(self, attr):
9 | # return getattr(self.rest_api_base, attr)
10 |
11 | #
12 | # Internal REST API Helpers (mostly XML definitions that are reused between methods)
13 | #
14 | @staticmethod
15 | def build_site_request_xml(site_name: Optional[str] = None, content_url: Optional[str] = None,
16 | options_dict: Optional[Dict] = None):
17 | tsr = ET.Element("tsRequest")
18 | s = ET.Element('site')
19 |
20 | if site_name is not None:
21 | s.set('name', site_name)
22 | if content_url is not None:
23 | s.set('contentUrl', content_url)
24 | if options_dict is not None:
25 | for key in options_dict:
26 | if str(options_dict[key]).lower() in ['true', 'false']:
27 | s.set(key, str(options_dict[key]).lower())
28 | else:
29 | s.set(key, str(options_dict[key]))
30 |
31 | tsr.append(s)
32 | return tsr
33 |
34 | #
35 | # Start Site Querying Methods
36 | #
37 |
38 | # Site queries don't have the site portion of the URL, so login option gets correct format
39 | def query_sites(self) -> ET.Element:
40 | self.rest.start_log_block()
41 | sites = self.rest.query_resource("sites", server_level=True)
42 | self.rest.end_log_block()
43 | return sites
44 |
45 | def query_sites_json(self, page_number: Optional[int] = None) -> Dict:
46 | self.rest.start_log_block()
47 | sites = self.rest.query_resource_json("sites", server_level=True, page_number=page_number)
48 | self.rest.end_log_block()
49 | return sites
50 |
51 | # Methods for getting info about the sites, since you can only query a site when you are signed into it
52 |
53 | # Return list of all site contentUrls
54 | def query_all_site_content_urls(self) -> List[str]:
55 | self.rest.start_log_block()
56 | sites = self.query_sites()
57 | site_content_urls = []
58 | for site in sites:
59 | site_content_urls.append(site.get("contentUrl"))
60 | self.rest.end_log_block()
61 | return site_content_urls
62 |
63 | # You can only query a site you have logged into this way. Better to use methods that run through query_sites
64 | def query_current_site(self) -> ET.Element:
65 | self.rest.start_log_block()
66 | site = self.rest.query_resource("sites/{}".format(self.rest.site_luid), server_level=True)
67 | self.rest.end_log_block()
68 | return site
69 |
70 | # Both SiteName and ContentUrl must be unique to add a site
71 | def create_site(self, new_site_name: str, new_content_url: str, options_dict: Optional[Dict] = None,
72 | direct_xml_request: Optional[ET.Element] = None) -> str:
73 |
74 | if direct_xml_request is not None:
75 | tsr = direct_xml_request
76 | else:
77 | tsr = self.build_site_request_xml(site_name=new_site_name, content_url=new_content_url,
78 | options_dict=options_dict)
79 | url = self.rest.build_api_url("sites/",
80 | server_level=True) # Site actions drop back out of the site ID hierarchy like login
81 | try:
82 | new_site = self.rest.send_add_request(url, tsr)
83 | return new_site.findall('.//t:site', self.rest.ns_map)[0].get("id")
84 | except RecoverableHTTPException as e:
85 | if e.http_code == 409:
86 | self.rest.log("Site with content_url {} already exists".format(new_content_url))
87 | self.rest.end_log_block()
88 | raise AlreadyExistsException("Site with content_url {} already exists".format(new_content_url),
89 | new_content_url)
90 |
91 | # Can only update the site you are signed into, so take site_luid from the object
92 | def update_site(self, site_name: Optional[str] = None, content_url: Optional[str] = None,
93 | options_dict: Optional[Dict] = None, direct_xml_request: Optional[ET.Element] = None) -> ET.Element:
94 | self.rest.start_log_block()
95 | if direct_xml_request is not None:
96 | tsr = direct_xml_request
97 | else:
98 | tsr = self.build_site_request_xml(site_name=site_name, content_url=content_url, options_dict=options_dict)
99 | url = self.rest.build_api_url("")
100 | response = self.rest.send_update_request(url, tsr)
101 | self.rest.end_log_block()
102 | return response
103 |
104 | # Can only delete a site that you have signed into
105 | def delete_current_site(self):
106 | self.rest.start_log_block()
107 | url = self.rest.build_api_url("sites/{}".format(self.rest.site_luid), server_level=True)
108 | self.rest.send_delete_request(url)
109 | self.rest.end_log_block()
110 |
111 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/user.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 |
4 | class UserMethods():
5 | def __init__(self, rest_api_base: TableauRestApiBase):
6 | self.rest = rest_api_base
7 |
8 | #def __getattr__(self, attr):
9 | # return getattr(self.rest_api_base, attr)
10 |
11 | # The reference has this name, so for consistency adding an alias
12 | def get_users(self, all_fields: bool = True, last_login_filter: Optional[UrlFilter] = None,
13 | site_role_filter: Optional[UrlFilter] = None, sorts: Optional[List[Sort]] = None,
14 | fields: Optional[List[str] ] =None) -> ET.Element:
15 | return self.query_users(all_fields=all_fields, last_login_filter=last_login_filter,
16 | site_role_filter=site_role_filter, sorts=sorts, fields=fields)
17 |
18 | def query_users(self, all_fields: bool = True, last_login_filter: Optional[UrlFilter] = None,
19 | site_role_filter: Optional[UrlFilter] = None, username_filter: Optional[UrlFilter] = None,
20 | sorts: Optional[List[Sort]] = None, fields: Optional[List[str] ] =None) -> ET.Element:
21 | self.rest.start_log_block()
22 | if fields is None:
23 | if all_fields is True:
24 | fields = ['_all_']
25 |
26 | filter_checks = {'lastLogin': last_login_filter, 'siteRole': site_role_filter, 'name': username_filter}
27 | filters = self.rest._check_filter_objects(filter_checks)
28 |
29 | users = self.rest.query_resource("users", filters=filters, sorts=sorts, fields=fields)
30 | self.rest.log('Found {} users'.format(str(len(users))))
31 | self.rest.end_log_block()
32 | return users
33 |
34 | # The reference has this name, so for consistency adding an alias
35 | def get_users_json(self, all_fields: bool = True, last_login_filter: Optional[UrlFilter] = None,
36 | site_role_filter: Optional[UrlFilter] = None, username_filter: Optional[UrlFilter] = None,
37 | sorts: Optional[List[Sort]] = None, fields: Optional[List[str] ] =None,
38 | page_number: Optional[int] = None) -> Dict:
39 | return self.query_users_json(all_fields=all_fields, last_login_filter=last_login_filter,
40 | site_role_filter=site_role_filter, username_filter=username_filter, sorts=sorts,
41 | fields=fields, page_number=page_number)
42 |
43 | def query_users_json(self, all_fields: bool = True, last_login_filter: Optional[UrlFilter] = None,
44 | site_role_filter: Optional[UrlFilter] = None, username_filter: Optional[UrlFilter] = None,
45 | sorts: Optional[List[Sort]] = None, fields: Optional[List[str]] = None,
46 | page_number: Optional[int] = None) -> Dict:
47 |
48 | self.rest.start_log_block()
49 | if fields is None:
50 | if all_fields is True:
51 | fields = ['_all_']
52 |
53 | filter_checks = {'lastLogin': last_login_filter, 'siteRole': site_role_filter, 'name': username_filter}
54 | filters = self.rest._check_filter_objects(filter_checks)
55 |
56 | users = self.rest.query_resource_json("users", filters=filters, sorts=sorts, fields=fields, page_number=page_number)
57 |
58 | self.rest.log('Found {} users'.format(str(len(users))))
59 | self.rest.end_log_block()
60 | return users
61 |
62 | def query_user(self, username_or_luid: str, all_fields: bool = True) -> ET.Element:
63 | self.rest.start_log_block()
64 | user = self.rest.query_single_element_from_endpoint_with_filter("user", username_or_luid, all_fields=all_fields)
65 | user_luid = user.get("id")
66 | username = user.get('name')
67 | self.rest.username_luid_cache[username] = user_luid
68 | self.rest.end_log_block()
69 | return user
70 |
71 | def query_username(self, user_luid: str) -> str:
72 | self.rest.start_log_block()
73 | try:
74 | luid_index = list(self.rest.username_luid_cache.values()).index(user_luid)
75 | username = list(self.rest.username_luid_cache.keys())[luid_index]
76 | except ValueError as e:
77 | user = self.query_user(user_luid)
78 | username = user.get('name')
79 |
80 | self.rest.end_log_block()
81 | return username
82 |
83 | def add_user_by_username(self, username: Optional[str] = None, site_role: Optional[str] = 'Unlicensed',
84 | auth_setting: Optional[str] = None, update_if_exists: bool = False,
85 | direct_xml_request: Optional[ET.Element] = None) -> str:
86 | self.rest.start_log_block()
87 |
88 | # Check to make sure role that is passed is a valid role in the API
89 | if site_role not in self.rest.site_roles:
90 | raise InvalidOptionException("{} is not a valid site role in Tableau Server".format(site_role))
91 |
92 | if auth_setting is not None:
93 | if auth_setting not in ['SAML', 'ServerDefault']:
94 | raise InvalidOptionException('auth_setting must be either "SAML" or "ServerDefault"')
95 | self.rest.log("Adding {}".format(username))
96 | if direct_xml_request is not None:
97 | tsr = direct_xml_request
98 | else:
99 | tsr = ET.Element("tsRequest")
100 | u = ET.Element("user")
101 | u.set("name", username)
102 | u.set("siteRole", site_role)
103 | if auth_setting is not None:
104 | u.set('authSetting', auth_setting)
105 | tsr.append(u)
106 |
107 | url = self.rest.build_api_url('users')
108 | try:
109 | new_user = self.rest.send_add_request(url, tsr)
110 | new_user_luid = new_user.findall('.//t:user', self.rest.ns_map)[0].get("id")
111 | self.rest.end_log_block()
112 | return new_user_luid
113 | # If already exists, update site role unless overridden.
114 | except RecoverableHTTPException as e:
115 | if e.http_code == 409:
116 | self.rest.log("Username '{}' already exists on the server".format(username))
117 | if update_if_exists is True:
118 | self.rest.log('Updating {} to site role {}'.format(username, site_role))
119 | self.update_user(username, site_role=site_role)
120 | self.rest.end_log_block()
121 | return self.rest.query_user_luid(username)
122 | else:
123 | self.rest.end_log_block()
124 | raise AlreadyExistsException('Username already exists ', self.rest.query_user_luid(username))
125 | except:
126 | self.rest.end_log_block()
127 | raise
128 |
129 | # This is "Add User to Site", since you must be logged into a site.
130 | # Set "update_if_exists" to True if you want the equivalent of an 'upsert', ignoring the exceptions
131 | def add_user(self, username: Optional[str] = None, fullname: Optional[str] = None,
132 | site_role: Optional[str] = 'Unlicensed', password: Optional[str] = None,
133 | email: Optional[str] = None, auth_setting: Optional[str] = None,
134 | update_if_exists: bool = False, direct_xml_request: Optional[ET.Element] = None) -> str:
135 |
136 | self.rest.start_log_block()
137 |
138 | try:
139 | # Add username first, then update with full name
140 | if direct_xml_request is not None:
141 | # Parse to second level, should be
142 | new_user_tsr = ET.Element('tsRequest')
143 | new_user_u = ET.Element('user')
144 | for t in direct_xml_request:
145 | if t.tag != 'user':
146 | raise InvalidOptionException('Must submit a tsRequest with a user element')
147 | for a in t.attrib:
148 | if a in ['name', 'siteRole', 'authSetting']:
149 | new_user_u.set(a, t.attrib[a])
150 | new_user_tsr.append(new_user_u)
151 | new_user_luid = self.add_user_by_username(direct_xml_request=new_user_tsr)
152 |
153 | update_tsr = ET.Element('tsRequest')
154 | update_u = ET.Element('user')
155 | for t in direct_xml_request:
156 | for a in t.attrib:
157 | if a in ['fullName', 'email', 'password', 'siteRole', 'authSetting']:
158 | update_u.set(a, t.attrib[a])
159 | update_tsr.append(update_u)
160 | self.update_user(username_or_luid=new_user_luid, direct_xml_request=update_tsr)
161 |
162 | else:
163 | new_user_luid = self.add_user_by_username(username, site_role=site_role,
164 | update_if_exists=update_if_exists, auth_setting=auth_setting)
165 | self.update_user(new_user_luid, fullname, site_role, password, email)
166 | self.rest.end_log_block()
167 | return new_user_luid
168 | except AlreadyExistsException as e:
169 | self.rest.log("Username '{}' already exists on the server; no updates performed".format(username))
170 | self.rest.end_log_block()
171 | return e.existing_luid
172 |
173 | def update_user(self, username_or_luid: str, full_name: Optional[str] = None, site_role: Optional[str] =None,
174 | password: Optional[str] = None,
175 | email: Optional[str] = None, direct_xml_request: Optional[ET.Element] = None) -> ET.Element:
176 |
177 | self.rest.start_log_block()
178 | user_luid = self.rest.query_user_luid(username_or_luid)
179 |
180 | if direct_xml_request is not None:
181 | tsr = direct_xml_request
182 | else:
183 | tsr = ET.Element("tsRequest")
184 | u = ET.Element("user")
185 | if full_name is not None:
186 | u.set('fullName', full_name)
187 | if site_role is not None:
188 | u.set('siteRole', site_role)
189 | if email is not None:
190 | u.set('email', email)
191 | if password is not None:
192 | u.set('password', password)
193 | tsr.append(u)
194 |
195 | url = self.rest.build_api_url("users/{}".format(user_luid))
196 | response = self.rest.send_update_request(url, tsr)
197 | self.rest.end_log_block()
198 | return response
199 |
200 | # Can take collection or single user_luid string
201 | def remove_users_from_site(self, username_or_luid_s: Union[List[str], str]):
202 | self.rest.start_log_block()
203 | users = self.rest.to_list(username_or_luid_s)
204 | for user in users:
205 | user_luid = self.rest.query_user_luid(user)
206 | url = self.rest.build_api_url("users/{}".format(user_luid))
207 | self.rest.log('Removing user id {} from site'.format(user_luid))
208 | self.rest.send_delete_request(url)
209 | self.rest.end_log_block()
210 |
211 | def unlicense_users(self, username_or_luid_s: Union[List[str], str]):
212 | self.rest.start_log_block()
213 | users = self.rest.to_list(username_or_luid_s)
214 | for user in users:
215 | user_luid = self.rest.query_user_luid(user)
216 | self.rest.update_user(username_or_luid=user_luid, site_role="Unlicensed")
217 | self.rest.end_log_block()
218 |
--------------------------------------------------------------------------------
/tableau_rest_api/methods/webhooks.py:
--------------------------------------------------------------------------------
1 | from .rest_api_base import *
2 |
3 | # First Metadata Methods appear in API 3.5
4 | class WebhooksMethods36():
5 | def __init__(self, rest_api_base: TableauRestApiBase36):
6 | self.rest = rest_api_base
7 |
8 | #def __getattr__(self, attr):
9 | # return getattr(self.rest_api_base, attr)
10 |
11 | def create_webhook(self, webhook_name: str, webhook_source_api_event_name: str, url: str) -> ET.Element:
12 | self.rest.start_log_block()
13 | tsr = ET.Element('tsRequest')
14 | wh = ET.Element('webhook')
15 | wh.set('name', webhook_name)
16 |
17 | whs = ET.Element('webhook-source')
18 | whs_event = ET.Element(webhook_source_api_event_name)
19 | whs.append(whs_event)
20 |
21 | whd = ET.Element('webhook-destination')
22 |
23 | whdh = ET.Element('webhook-destination-http')
24 | whdh.set('method', 'POST')
25 | whdh.set('url', url)
26 | whd.append(whd)
27 |
28 | wh.append(whs)
29 | wh.append(whd)
30 |
31 | tsr.append(wh)
32 | url = self.rest.build_api_url("webhooks")
33 | response = self.rest.send_add_request(url=url, request=tsr)
34 | self.rest.end_log_block()
35 | return response
36 |
37 | def list_webhooks(self) -> ET.Element:
38 | self.rest.start_log_block()
39 | response = self.rest.query_resource("webhooks")
40 | self.rest.end_log_block()
41 | return response
42 |
43 | def test_webhook(self, webhook_name_or_luid: str) -> ET.Element:
44 | self.rest.start_log_block()
45 | webhook_luid = self.rest.query_webhook_luid(webhook_name_or_luid)
46 | response = self.rest.query_resource("webhooks/{}/test".format(webhook_luid))
47 | self.rest.end_log_block()
48 | return response
49 |
50 | def delete_webhook(self, webhook_name_or_luid: str):
51 | self.rest.start_log_block()
52 | webhook_luid = self.rest.query_webhook_luid(webhook_name_or_luid)
53 | url = self.rest.build_api_url("webhooks/{}".format(webhook_luid))
54 | self.rest.delete_resource(url)
55 | self.rest.end_log_block()
56 |
57 | def get_webhook(self, webhook_name_or_luid: str) -> ET.Element:
58 | self.rest.start_log_block()
59 | webhook_luid = self.rest.query_webhook_luid(webhook_name_or_luid)
60 | response = self.rest.query_resource("webhooks/{}".format(webhook_luid))
61 | self.rest.end_log_block()
62 | return response
63 |
--------------------------------------------------------------------------------
/tableau_rest_api/sort.py:
--------------------------------------------------------------------------------
1 | from ..tableau_exceptions import *
2 |
3 |
4 | class Sort:
5 | def __init__(self, field: str, direction: str):
6 | self.field = field
7 | self.direction = direction
8 |
9 | def get_sort_string(self) -> str:
10 | sort_string = '{}:{}'.format(self.field, self.direction)
11 | return sort_string
12 |
13 | @staticmethod
14 | def Ascending(field: str) -> 'Sort':
15 | return Sort(field=field, direction='asc')
16 |
17 | @staticmethod
18 | def Descending(field: str) -> 'Sort':
19 | return Sort(field=field, direction='desc')
20 |
--------------------------------------------------------------------------------
/tableau_rest_api/url_filter.py:
--------------------------------------------------------------------------------
1 | from ..tableau_exceptions import *
2 | from typing import Union, Optional, List, Dict, Tuple
3 | import datetime
4 |
5 | class UrlFilter:
6 | def __init__(self, field: str, operator: str, values: List[str]):
7 | self.field = field
8 | self.operator = operator
9 | self.values = values
10 |
11 | @staticmethod
12 | def datetime_to_tableau_date_str(dt: Union[str, datetime.datetime]) -> str:
13 | if isinstance(dt, datetime.datetime):
14 | return dt.isoformat('T')[:19] + 'Z'
15 | else:
16 | return dt
17 |
18 |
19 | def get_filter_string(self) -> str:
20 | if len(self.values) == 0:
21 | raise InvalidOptionException('Must pass in at least one value for the filter')
22 | elif len(self.values) == 1:
23 | value_string = self.values[0]
24 | else:
25 | value_string = ",".join(self.values)
26 | value_string = "[{}]".format(value_string)
27 | url = "{}:{}:{}".format(self.field, self.operator, value_string)
28 | return url
29 |
30 | # Users, Datasources, Views, Workbooks
31 | @staticmethod
32 | def get_name_filter(name: str) -> 'UrlFilter':
33 | return UrlFilter('name', 'eq', [name, ])
34 |
35 | # Users
36 | @staticmethod
37 | def get_last_login_filter(operator: str, last_login_time: Union[str, datetime.datetime]) -> 'UrlFilter':
38 | """
39 | :param operator: Should be one of 'eq', 'gt', 'gte', 'lt', 'lte'
40 | :param last_login_time: ISO 8601 representation of time like 2016-01-01T00:00:00:00Z
41 | """
42 | # Convert to the correct time format
43 | time = UrlFilter.datetime_to_tableau_date_str(last_login_time)
44 |
45 | return UrlFilter('lastLogin', operator, [time, ])
46 |
47 | # Users
48 | @staticmethod
49 | def get_site_role_filter(site_role: str) -> 'UrlFilter':
50 | return UrlFilter('siteRole', 'eq', [site_role, ])
51 |
52 | # Workbooks
53 | @staticmethod
54 | def get_owner_name_filter(owner_name: str) -> 'UrlFilter':
55 |
56 | return UrlFilter('ownerName', 'eq', [owner_name, ])
57 |
58 | # Workbooks, Datasources, Views, Jobs
59 | @staticmethod
60 | def get_created_at_filter(operator: str, created_at_time: Union[str, datetime.datetime]) -> 'UrlFilter':
61 | """
62 | :param operator: Should be one of 'eq', 'gt', 'gte', 'lt', 'lte'
63 | :param created_at_time: ISO 8601 representation of time like 2016-01-01T00:00:00:00Z
64 | """
65 | # Convert to the correct time format
66 | time = UrlFilter.datetime_to_tableau_date_str(created_at_time)
67 | return UrlFilter('createdAt', operator, [time, ])
68 |
69 | # Workbooks, Datasources, Views
70 | @staticmethod
71 | def get_updated_at_filter(operator: str, updated_at_time: Union[str, datetime.datetime]) -> 'UrlFilter':
72 | """
73 | :param operator: Should be one of 'eq', 'gt', 'gte', 'lt', 'lte'
74 | :param updated_at_time: ISO 8601 representation of time like 2016-01-01T00:00:00:00Z
75 | """
76 | # Convert to the correct time format
77 | time = updated_at_time
78 |
79 | return UrlFilter('updatedAt', operator, [time, ])
80 |
81 | # Workbooks, Datasources, Views
82 | @staticmethod
83 | def get_tags_filter(tags: List[str]) -> 'UrlFilter':
84 | return UrlFilter('tags', 'in', tags)
85 |
86 | # Workbooks, Datasources, Views
87 | @staticmethod
88 | def get_tag_filter(tag: str) -> 'UrlFilter':
89 | return UrlFilter('tags', 'eq', [tag, ])
90 |
91 | # Datasources
92 | @staticmethod
93 | def get_datasource_type_filter(ds_type: str) -> 'UrlFilter':
94 | return UrlFilter('type', 'eq', [ds_type, ])
95 |
96 | class UrlFilter27(UrlFilter):
97 | def __init__(self, field, operator, values):
98 | UrlFilter.__init__(self, field, operator, values)
99 | # Some of the previous methods add in methods
100 |
101 | # Users, Datasources, Views, Workbooks
102 | @staticmethod
103 | def get_names_filter(names: List[str]) -> 'UrlFilter':
104 | return UrlFilter('name', 'in', names)
105 |
106 | # Users
107 | @staticmethod
108 | def get_site_roles_filter(site_roles: List[str]) -> 'UrlFilter':
109 | return UrlFilter('siteRole', 'in', site_roles)
110 |
111 | # Workbooks, Projects
112 | @staticmethod
113 | def get_owner_names_filter(owner_names: List[str]) -> 'UrlFilter':
114 | return UrlFilter('ownerName', 'in', owner_names)
115 |
116 | # Workbooks. Datasources, Views
117 | # get_owner_name_filter (singular) allowed for all of them
118 |
119 | # Groups
120 | @staticmethod
121 | def get_domain_names_filter(domain_names: List[str]) -> 'UrlFilter':
122 | return UrlFilter('domainName', 'in', domain_names)
123 |
124 | # Groups
125 | @staticmethod
126 | def get_domain_nicknames_filter(domain_nicknames: List[str]) -> 'UrlFilter':
127 |
128 | return UrlFilter('domainNickname', 'in', domain_nicknames)
129 |
130 | # Groups
131 | @staticmethod
132 | def get_domain_name_filter(domain_name: str) -> 'UrlFilter':
133 | return UrlFilter('domainName', 'eq', [domain_name, ])
134 |
135 | # Groups
136 | @staticmethod
137 | def get_domain_nickname_filter(domain_nickname: str) -> 'UrlFilter':
138 |
139 | return UrlFilter('domainNickname', 'eq', [domain_nickname, ])
140 |
141 | # Groups
142 | @staticmethod
143 | def get_minimum_site_roles_filter(minimum_site_roles: List[str]) -> 'UrlFilter':
144 | return UrlFilter('minimumSiteRole', 'in', minimum_site_roles)
145 |
146 | # Groups
147 | @staticmethod
148 | def get_minimum_site_role_filter(minimum_site_role: str) -> 'UrlFilter':
149 | return UrlFilter('minimumSiteRole', 'eq', [minimum_site_role, ])
150 |
151 | # Groups
152 | @staticmethod
153 | def get_is_local_filter(is_local: bool) -> 'UrlFilter':
154 | if is_local not in [True, False]:
155 | raise InvalidOptionException('is_local must be True or False')
156 | return UrlFilter('isLocal', 'eq', [str(is_local).lower(), ])
157 |
158 | # Groups
159 | @staticmethod
160 | def get_user_count_filter(operator, user_count: int) -> 'UrlFilter':
161 | return UrlFilter('userCount', operator, [str(user_count), ])
162 |
163 | # Projects
164 | @staticmethod
165 | def get_owner_domains_filter(owner_domains: List[str]) -> 'UrlFilter':
166 | return UrlFilter('ownerDomain', 'in', owner_domains)
167 |
168 | # Projects
169 | @staticmethod
170 | def get_owner_domain_filter(owner_domain: str) -> 'UrlFilter':
171 | return UrlFilter('ownerDomain', 'in', [owner_domain, ])
172 |
173 | # Projects
174 | @staticmethod
175 | def get_owner_emails_filter(owner_emails: List[str]) -> 'UrlFilter':
176 | return UrlFilter('ownerEmail', 'in', owner_emails)
177 |
178 | # Projects
179 | @staticmethod
180 | def get_owner_email_filter(owner_email: str) -> 'UrlFilter':
181 | return UrlFilter('ownerEmail', 'in', [owner_email, ])
182 |
183 | # Views
184 | @staticmethod
185 | def get_hits_total_filter(operator, hits_total: int) -> 'UrlFilter':
186 | # Convert to the correct time format
187 |
188 | return UrlFilter('hitsTotal', operator, [str(hits_total), ])
189 |
190 |
191 | class UrlFilter28(UrlFilter27):
192 | def __init__(self, field, operator, values):
193 | UrlFilter27.__init__(self, field, operator, values)
194 | # No changes in 2.8
195 |
196 |
197 | class UrlFilter30(UrlFilter28):
198 | def __init__(self, field, operator, values):
199 | UrlFilter28.__init__(self, field, operator, values)
200 | # No changes in 3.0
201 |
202 |
203 | class UrlFilter31(UrlFilter30):
204 | def __init__(self, field, operator, values):
205 | UrlFilter30.__init__(self, field, operator, values)
206 |
207 | # Jobs
208 | @staticmethod
209 | def get_started_at_filter(operator: str, started_at_time: str) -> UrlFilter:
210 | """
211 | :param operator: Should be one of 'eq', 'gt', 'gte', 'lt', 'lte'
212 | :param started_at_time: ISO 8601 representation of time like 2016-01-01T00:00:00:00Z
213 | """
214 | # Convert to the correct time format
215 | time = UrlFilter.datetime_to_tableau_date_str(started_at_time)
216 | return UrlFilter('createdAt', operator, [time, ])
217 |
218 | # Jobs
219 | @staticmethod
220 | def get_ended_at_filter(operator: str, ended_at_time: str) -> UrlFilter:
221 | """
222 | :param operator: Should be one of 'eq', 'gt', 'gte', 'lt', 'lte'
223 | :param ended_at_time: ISO 8601 representation of time like 2016-01-01T00:00:00:00Z
224 | """
225 | # Convert to the correct time format
226 | time = UrlFilter.datetime_to_tableau_date_str(ended_at_time)
227 | return UrlFilter('createdAt', operator, [time, ])
228 |
229 | # Jobs
230 | @staticmethod
231 | def get_job_types_filter(job_types: List[str]) -> UrlFilter:
232 | return UrlFilter('jobType', 'in', job_types)
233 |
234 | # Jobs
235 | @staticmethod
236 | def get_job_type_filter(job_type: str) -> UrlFilter:
237 | return UrlFilter('tags', 'eq', [job_type, ])
238 |
239 | # Jobs
240 | @staticmethod
241 | def get_notes_filter(notes: str) -> UrlFilter:
242 | return UrlFilter('notes', 'has', [notes, ])
243 |
244 | @staticmethod
245 | def get_title_equals_filter(title: str) -> UrlFilter:
246 | return UrlFilter('title', 'eq', [title, ])
247 |
248 | @staticmethod
249 | def get_title_has_filter(title: str) -> UrlFilter:
250 | return UrlFilter('title', 'has', [title, ])
251 |
252 | @staticmethod
253 | def get_subtitle_equals_filter(subtitle: str) -> UrlFilter:
254 | return UrlFilter('subtitle', 'eq', [subtitle, ])
255 |
256 | @staticmethod
257 | def get_subtitle_has_filter(subtitle: str) -> UrlFilter:
258 | return UrlFilter('subtitle', 'has', [subtitle, ])
259 |
260 | class UrlFilter33(UrlFilter31):
261 | def __init__(self, field, operator, values):
262 | UrlFilter31.__init__(self, field, operator, values)
263 |
264 | @staticmethod
265 | def get_project_name_equals_filter(project_name: str) -> UrlFilter:
266 | return UrlFilter('projectName', 'eq', [project_name, ])
267 |
--------------------------------------------------------------------------------
/tableau_rest_xml.py:
--------------------------------------------------------------------------------
1 | # This is intended to be full of static helper methods
2 | import xml.etree.ElementTree as ET
3 | import random
4 | from typing import Union, Optional, List, Dict, Tuple
5 | import re
6 |
7 | class TableauRestXml:
8 | tableau_namespace = 'http://tableau.com/api'
9 | ns_map = {'t': 'http://tableau.com/api'}
10 | ns_prefix = '{' + ns_map['t'] + '}'
11 | # ET.register_namespace('t', ns_map['t'])
12 | # Generic method for XML lists for the "query" actions to name -> id dict
13 | @staticmethod
14 | def convert_xml_list_to_name_id_dict(xml_obj: ET.Element) -> Dict:
15 | d = {}
16 | for element in xml_obj:
17 | e_id = element.get("id")
18 | # If list is collection, have to run one deeper
19 | if e_id is None:
20 | for list_element in element:
21 | e_id = list_element.get("id")
22 | name = list_element.get("name")
23 | d[name] = e_id
24 | else:
25 | name = element.get("name")
26 | d[name] = e_id
27 | return d
28 |
29 | # Repeat of above method with shorter name
30 | @staticmethod
31 | def xml_list_to_dict(xml_obj: ET.Element) -> Dict:
32 | d = {}
33 | for element in xml_obj:
34 | e_id = element.get("id")
35 | # If list is collection, have to run one deeper
36 | if e_id is None:
37 | for list_element in element:
38 | e_id = list_element.get("id")
39 | name = list_element.get("name")
40 | d[name] = e_id
41 | else:
42 | name = element.get("name")
43 | d[name] = e_id
44 | return d
45 |
46 | @staticmethod
47 | def luid_name_dict_from_xml(xml_obj: ET.Element) -> Dict:
48 | d = {}
49 | for element in xml_obj:
50 | e_id = element.get("id")
51 | # If list is collection, have to run one deeper
52 | if e_id is None:
53 | for list_element in element:
54 | e_id = list_element.get("id")
55 | name = list_element.get("name")
56 | d[e_id] = name
57 | else:
58 | name = element.get("name")
59 | d[e_id] = name
60 | return d
61 |
62 | @staticmethod
63 | def luid_content_url_dict_from_xml(xml_obj: ET.Element) -> Dict:
64 | d = {}
65 | for element in xml_obj:
66 | e_id = element.get("id")
67 | # If list is collection, have to run one deeper
68 | if e_id is None:
69 | for list_element in element:
70 | e_id = list_element.get("id")
71 | name = list_element.get("contentUrl")
72 | d[e_id] = name
73 | else:
74 | name = element.get("contentUrl")
75 | d[e_id] = name
76 | return d
77 |
78 | # This corrects for the first element in any response by the plural collection tag, which leads to differences
79 | # with the XPath search currently
80 | @staticmethod
81 | def make_xml_list_iterable(xml_obj: ET.Element) -> List[ET.Element]:
82 | pass
83 |
84 | # 32 hex characters with 4 dashes
85 | @staticmethod
86 | def is_luid(val: str) -> bool:
87 | luid_pattern = r"[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*-[0-9a-fA-F]*"
88 | if len(val) == 36:
89 | if re.match(luid_pattern, val) is not None:
90 | return True
91 | else:
92 | return False
93 | else:
94 | return False
95 |
96 | # URI is different form actual URL you need to load a particular view in iframe
97 | @staticmethod
98 | def convert_view_content_url_to_embed_url(content_url: str) -> str:
99 | split_url = content_url.split('/')
100 | return 'views/{}/{}'.format(split_url[0], split_url[2])
101 |
102 | # You must generate a boundary string that is used both in the headers and the generated request that you post.
103 | # This builds a simple 30 hex digit string
104 | @staticmethod
105 | def generate_boundary_string() -> str:
106 | random_digits = [random.SystemRandom().choice('0123456789abcdef') for n in range(30)]
107 | s = "".join(random_digits)
108 | return s
--------------------------------------------------------------------------------