├── .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 --------------------------------------------------------------------------------