diff --git a/.gitignore b/.gitignore index b6e4761..a485f18 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.DS_Store # Translations *.mo @@ -109,6 +110,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.idea/ # Spyder project settings .spyderproject diff --git a/tableau.png b/tableau.png new file mode 100644 index 0000000..5645021 Binary files /dev/null and b/tableau.png differ diff --git a/vendor_blueprints/__init__.py b/tableau_blueprints/__init__.py similarity index 100% rename from vendor_blueprints/__init__.py rename to tableau_blueprints/__init__.py diff --git a/tableau_blueprints/authorization.py b/tableau_blueprints/authorization.py new file mode 100644 index 0000000..ada96ca --- /dev/null +++ b/tableau_blueprints/authorization.py @@ -0,0 +1,49 @@ +import sys +import tableauserverclient as TSC + +EXIT_CODE_INVALID_CREDENTIALS = 200 + + +def connect_to_tableau( + username, + password, + site_id, + server_url, + sign_in_method): + """TSC library to sign in and sign out of Tableau Server and Tableau Online. + + :param username:The username or access token name of the user. + :param password:The password or access token of the user. + :param site_id: The site_id for required datasources. ex: ffc7f88a-85a7-48d5-ac03-09ef0a677280 + :param server_url: This corresponds to the contentUrl attribute in the Tableau REST API. + :param sign_in_method: Whether to log in with username_password or access_token. + :return: server object, connection object + """ + if sign_in_method == 'username_password': + tableau_auth = TSC.TableauAuth(username, password, site_id=site_id) + + if sign_in_method == 'access_token': + tableau_auth = TSC.PersonalAccessTokenAuth( + token_name=username, + personal_access_token=password, + site_id=site_id) + + try: + # Make sure we use an updated version of the rest apis. + server = TSC.Server( + server_url, + use_server_version=True, + ) + connection = server.auth.sign_in(tableau_auth) + print("Successfully authenticated with Tableau.") + except Exception as e: + print(f'Failed to connect to Tableau.') + if sign_in_method == 'username_password': + print('Invalid username or password. Please check for typos and try again.') + if sign_in_method == 'access_token': + print( + 'Invalid token name or access token. Please check for typos and try again.') + print(e) + sys.exit(EXIT_CODE_INVALID_CREDENTIALS) + + return server, connection diff --git a/tableau_blueprints/download_view.py b/tableau_blueprints/download_view.py new file mode 100644 index 0000000..310aecc --- /dev/null +++ b/tableau_blueprints/download_view.py @@ -0,0 +1,148 @@ +import argparse +import sys +import shipyard_utils as shipyard + +import tableauserverclient as TSC + +try: + import authorization + import errors + import lookup +except BaseException: + from . import authorization + from . import errors + from . import lookup + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--username', dest='username', required=True) + parser.add_argument('--password', dest='password', required=True) + parser.add_argument( + '--sign-in-method', + dest='sign_in_method', + default='username_password', + choices={ + 'username_password', + 'access_token'}, + required=False) + parser.add_argument('--site-id', dest='site_id', required=True) + parser.add_argument('--server-url', dest='server_url', required=True) + parser.add_argument('--view-name', dest='view_name', required=True) + parser.add_argument( + '--file-type', + dest='file_type', + choices=[ + 'png', + 'pdf', + 'csv'], + type=str.lower, + required=True) + parser.add_argument( + '--destination-file-name', + dest='destination_file_name', + default='output.csv', + required=True) + parser.add_argument( + '--destination-folder-name', + dest='destination_folder_name', + default='', + required=False) + parser.add_argument('--file-options', dest='file_options', required=False) + parser.add_argument('--workbook-name', dest='workbook_name', required=True) + parser.add_argument('--project-name', dest='project_name', required=True) + args = parser.parse_args() + return args + + +def generate_view_content(server, view_id, file_type): + """ + Given a specific view_id, populate the view and return the bytes necessary for creating the file. + """ + view_object = server.views.get_by_id(view_id) + if file_type == 'png': + server.views.populate_image(view_object) + view_content = view_object.image + if file_type == 'pdf': + server.views.populate_pdf(view_object, req_options=None) + view_content = view_object.pdf + if file_type == 'csv': + server.views.populate_csv(view_object, req_options=None) + view_content = view_object.csv + return view_content + + +def write_view_content_to_file( + destination_full_path, + view_content, + file_type, + view_name): + """ + Write the byte contents to the specified file path. + """ + try: + with open(destination_full_path, 'wb') as f: + if file_type == 'csv': + f.writelines(view_content) + else: + f.write(view_content) + print( + f'Successfully downloaded {view_name} to {destination_full_path}') + except OSError as e: + print(f'Could not write file: {destination_full_path}') + print(e) + sys.exit(errors.EXIT_CODE_FILE_WRITE_ERROR) + + +def main(): + args = get_args() + username = args.username + password = args.password + site_id = args.site_id + server_url = args.server_url + sign_in_method = args.sign_in_method + view_name = args.view_name + file_type = args.file_type + project_name = args.project_name + workbook_name = args.workbook_name + + # Set all file parameters + destination_file_name = args.destination_file_name + destination_folder_name = shipyard.files.clean_folder_name( + args.destination_folder_name) + destination_full_path = shipyard.files.combine_folder_and_file_name( + folder_name=destination_folder_name, file_name=destination_file_name) + + server, connection = authorization.connect_to_tableau( + username, + password, + site_id, + server_url, + sign_in_method) + + with connection: + project_id = lookup.get_project_id( + server=server, project_name=project_name) + workbook_id = lookup.get_workbook_id( + server=server, + project_id=project_id, + workbook_name=workbook_name) + view_id = lookup.get_view_id( + server=server, + project_id=project_id, + workbook_id=workbook_id, + view_name=view_name) + + view_content = generate_view_content( + server=server, view_id=view_id, file_type=file_type) + shipyard.files.create_folder_if_dne( + destination_folder_name=destination_folder_name) + write_view_content_to_file( + destination_full_path=destination_full_path, + view_content=view_content, + file_type=file_type, + view_name=view_name) + + +if __name__ == '__main__': + main() diff --git a/tableau_blueprints/errors.py b/tableau_blueprints/errors.py new file mode 100644 index 0000000..59c29cf --- /dev/null +++ b/tableau_blueprints/errors.py @@ -0,0 +1,16 @@ +EXIT_CODE_FINAL_STATUS_SUCCESS = 0 +EXIT_CODE_UNKNOWN_ERROR = 3 + +EXIT_CODE_FILE_WRITE_ERROR = 100 + +EXIT_CODE_INVALID_CREDENTIALS = 200 +EXIT_CODE_INVALID_PROJECT = 201 +EXIT_CODE_INVALID_WORKBOOK = 202 +EXIT_CODE_INVALID_VIEW = 203 +EXIT_CODE_INVALID_JOB = 204 +EXIT_CODE_INVALID_DATASOURCE = 205 +EXIT_CODE_REFRESH_ERROR = 206 + +EXIT_CODE_FINAL_STATUS_CANCELLED = 210 +EXIT_CODE_FINAL_STATUS_ERRORED = 211 +EXIT_CODE_STATUS_INCOMPLETE = 212 diff --git a/tableau_blueprints/job_status.py b/tableau_blueprints/job_status.py new file mode 100644 index 0000000..d8b37b8 --- /dev/null +++ b/tableau_blueprints/job_status.py @@ -0,0 +1,104 @@ +import tableauserverclient as TSC +import argparse +import sys +import shipyard_utils as shipyard + +try: + import errors + import authorization +except BaseException: + from . import errors + from . import authorization + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--username', dest='username', required=True) + parser.add_argument('--password', dest='password', required=True) + parser.add_argument('--site-id', dest='site_id', required=True) + parser.add_argument('--server-url', dest='server_url', required=True) + parser.add_argument( + '--sign-in-method', + dest='sign_in_method', + default='username_password', + choices={ + 'username_password', + 'access_token'}, + required=False) + parser.add_argument('--job-id', dest='job_id', required=False) + args = parser.parse_args() + return args + + +def get_job_info(server, job_id): + """ + Gets information about the specified job_id. + """ + try: + job_info = server.jobs.get_by_id(job_id) + except Exception as e: + print(f'Job {job_id} was not found.') + print(e) + sys.exit(errors.EXIT_CODE_INVALID_JOB) + return job_info + + +def determine_job_status(server, job_id): + """ + Job status response handler. + + The finishCode indicates the status of the job: -1 for incomplete, 0 for success, 1 for error, or 2 for cancelled. + """ + job_info = get_job_info(server, job_id) + if job_info.finish_code == -1: + if job_info.started_at is None: + print( + f'Tableau reports that the job {job_id} has not yet started.') + else: + print( + f'Tableau reports that the job {job_id} is not yet complete.') + exit_code = errors.EXIT_CODE_STATUS_INCOMPLETE + elif job_info.finish_code == 0: + print(f'Tableau reports that job {job_id} was successful.') + exit_code = errors.EXIT_CODE_FINAL_STATUS_SUCCESS + elif job_info.finish_code == 1: + print(f'Tableau reports that job {job_id} errored.') + exit_code = errors.EXIT_CODE_FINAL_STATUS_ERRORED + elif job_info.finish_code == 2: + print(f'Tableau reports that job {job_id} was cancelled.') + exit_code = errors.EXIT_CODE_FINAL_STATUS_CANCELLED + else: + print(f'Something went wrong when fetching status for job {job_id}') + exit_code = errors.EXIT_CODE_UNKNOWN_ERROR + return exit_code + + +def main(): + args = get_args() + username = args.username + password = args.password + site_id = args.site_id + server_url = args.server_url + sign_in_method = args.sign_in_method + + base_folder_name = shipyard.logs.determine_base_artifact_folder( + 'tableau') + artifact_subfolder_paths = shipyard.logs.determine_artifact_subfolders( + base_folder_name) + shipyard.logs.create_artifacts_folders(artifact_subfolder_paths) + + if args.job_id: + job_id = args.job_id + else: + job_id = shipyard.logs.read_pickle_file( + artifact_subfolder_paths, 'job_id') + + server, connection = authorization.connect_to_tableau( + username, password, site_id, server_url, sign_in_method) + + with connection: + sys.exit(determine_job_status(server, job_id)) + + +if __name__ == '__main__': + main() diff --git a/tableau_blueprints/lookup.py b/tableau_blueprints/lookup.py new file mode 100644 index 0000000..b65186e --- /dev/null +++ b/tableau_blueprints/lookup.py @@ -0,0 +1,97 @@ +import sys +import tableauserverclient as TSC + +try: + import errors +except BaseException: + from . import errors + + +def get_project_id(server, project_name): + """ + Looks up and returns the project_id of the project_name that was specified. + """ + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + project_name)) + + project_matches = server.projects.get(req_options=req_option) + if len(project_matches[0]) == 1: + project_id = project_matches[0][0].id + else: + print( + f'{project_name} could not be found. Please check for typos and ensure that the name you provide matches exactly (case sensitive)') + sys.exit(errors.EXIT_CODE_INVALID_PROJECT) + return project_id + + +def get_datasource_id(server, project_id, datasource_name): + """ + Looks up and returns the datasource_id of the datasource_name that was specified, filtered by project_id matches. + """ + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + datasource_name)) + + datasource_matches = server.datasources.get(req_options=req_option) + + # We can't filter by project_id or project_name in the initial request, + # so we have to find all name matches and look for a project_id match. + datasource_id = None + for datasource in datasource_matches[0]: + if datasource.project_id == project_id: + datasource_id = datasource.id + if datasource_id is None: + print( + f'{datasource_name} could not be found that lives in the project you specified. Please check for typos and ensure that the name(s) you provide match exactly (case sensitive)') + sys.exit(errors.EXIT_CODE_INVALID_DATASOURCE) + return datasource_id + + +def get_workbook_id(server, project_id, workbook_name): + """ + Looks up and returns the workbook_id of the workbook_name that was specified, filtered by project_id matches. + """ + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + workbook_name)) + + workbook_matches = server.workbooks.get(req_options=req_option) + # We can't filter by project_id in the initial request, + # so we have to find all name matches and look for a project_id match. + workbook_id = None + for workbook in workbook_matches[0]: + if workbook.project_id == project_id: + workbook_id = workbook.id + if workbook_id is None: + print( + f'{workbook_name} could not be found in the project you specified. Please check for typos and ensure that the name(s) you provide match exactly (case sensitive)') + sys.exit(errors.EXIT_CODE_INVALID_WORKBOOK) + return workbook_id + + +def get_view_id(server, project_id, workbook_id, view_name): + """ + Looks up and returns the view_id of the view_name that was specified, filtered by project_id AND workbook_id matches. + """ + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.Equals, + view_name)) + + view_matches = server.views.get(req_options=req_option) + # We can't filter by project_id or workbook_id in the initial request, + # so we have to find all name matches and look for those matches. + view_id = None + for view in view_matches[0]: + if view.project_id == project_id: + if view.workbook_id == workbook_id: + view_id = view.id + if view_id is None: + print( + f'{view_name} could not be found that lives in the project and workbook you specified. Please check for typos and ensure that the name(s) you provide match exactly (case sensitive)') + sys.exit(errors.EXIT_CODE_INVALID_VIEW) + return view_id diff --git a/tableau_blueprints/refresh_resource.py b/tableau_blueprints/refresh_resource.py new file mode 100644 index 0000000..efcddfb --- /dev/null +++ b/tableau_blueprints/refresh_resource.py @@ -0,0 +1,152 @@ +import tableauserverclient as TSC +import argparse +import sys +import shipyard_utils as shipyard + +# Handle import difference between local and github install +try: + import job_status + import errors + import authorization + import lookup +except BaseException: + from . import job_status + from . import errors + from . import authorization + from . import lookup + + +def get_args(): + parser = argparse.ArgumentParser() + parser.add_argument('--username', dest='username', required=True) + parser.add_argument('--password', dest='password', required=True) + parser.add_argument('--site-id', dest='site_id', required=True) + parser.add_argument('--server-url', dest='server_url', required=True) + parser.add_argument( + '--sign-in-method', + dest='sign_in_method', + default='username_password', + choices={ + 'username_password', + 'access_token'}, + required=False) + parser.add_argument( + '--workbook-name', + dest='workbook_name', + required=False) + parser.add_argument( + '--datasource-name', + dest='datasource_name', + required=False) + parser.add_argument('--project-name', dest='project_name', required=True) + parser.add_argument('--check-status', dest='check_status', default='TRUE', + choices={ + 'TRUE', + 'FALSE'}, + required=False) + args = parser.parse_args() + return args + + +def refresh_datasource(server, datasource_id, datasource_name): + """ + Refreshes the data of the specified datasource_id. + """ + + try: + datasource = server.datasources.get_by_id(datasource_id) + refreshed_datasource = server.datasources.refresh(datasource) + print(f'Datasource {datasource_name} was successfully triggered.') + except Exception as e: + if 'Resource Conflict' in e.args[0]: + print( + f'A refresh or extract operation for the datasource is already underway.') + if 'is not allowed.' in e.args[0]: + print(f'Refresh or extract operation for the datasource is not allowed.') + else: + print(f'An unknown refresh or extract error occurred.') + print(e) + sys.exit(errors.EXIT_CODE_REFRESH_ERROR) + + return refreshed_datasource + + +def refresh_workbook(server, workbook_id, workbook_name): + """ + Refreshes the data of the specified datasource_id. + """ + + try: + workbook = server.workbooks.get_by_id(workbook_id) + refreshed_workbook = server.workbooks.refresh(workbook) + print(f'Workbook {workbook_name} was successfully triggered.') + except Exception as e: + if 'Resource Conflict' in e.args[0]: + print( + f'A refresh or extract operation for the workbook is already underway.') + if 'is not allowed.' in e.args[0]: + print(f'Refresh or extract operation for the workbook is not allowed.') + else: + print(f'An unknown refresh or extract error occurred.') + print(e) + sys.exit(errors.EXIT_CODE_REFRESH_ERROR) + + return refreshed_datasource + + +def main(): + args = get_args() + username = args.username + password = args.password + site_id = args.site_id + server_url = args.server_url + workbook_name = args.workbook_name + datasource_name = args.datasource_name + project_name = args.project_name + sign_in_method = args.sign_in_method + should_check_status = shipyard.args.convert_to_boolean(args.check_status) + + base_folder_name = shipyard.logs.determine_base_artifact_folder( + 'tableau') + artifact_subfolder_paths = shipyard.logs.determine_artifact_subfolders( + base_folder_name) + shipyard.logs.create_artifacts_folders(artifact_subfolder_paths) + + server, connection = authorization.connect_to_tableau( + username, password, site_id, server_url, sign_in_method) + + with connection: + project_id = lookup.get_project_id(server, project_name) + + # Allowing user to provide one or the other. These will form two separate Blueprints + # that use the same underlying script. + if datasource_name: + datasource_id = lookup.get_datasource_id( + server, project_id, datasource_name) + refreshed_datasource = refresh_datasource( + server, datasource_id, datasource_name) + job_id = refreshed_datasource.id + if workbook_name: + workbook_id = lookup.get_workbook_id( + server, project_id, workbook_name) + refreshed_workbook = refresh_workbook( + server, workbook_id, workbook_name) + job_id = refreshed_workbook.id + + if should_check_status: + try: + # `wait_for_job` will automatically check every few seconds + # and throw if the job isn't executed successfully + print('Waiting for the job to complete...') + server.jobs.wait_for_job(job_id) + exit_code = job_status.determine_job_status(server, job_id) + except BaseException: + exit_code = job_status.determine_job_status(server, job_id) + sys.exit(exit_code) + else: + shipyard.logs.create_pickle_file( + artifact_subfolder_paths, 'job_id', job_id) + + +if __name__ == '__main__': + main() diff --git a/tableau_blueprints/requirements.txt b/tableau_blueprints/requirements.txt new file mode 100644 index 0000000..fb20946 --- /dev/null +++ b/tableau_blueprints/requirements.txt @@ -0,0 +1,3 @@ +requests==2.28.0 +tableauserverclient==0.19.0 +shipyard_utils>=0.1.2 \ No newline at end of file diff --git a/vendor_blueprints/requirements.txt b/vendor_blueprints/requirements.txt deleted file mode 100644 index 67dfcb2..0000000 --- a/vendor_blueprints/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# List out the required python packages for any of these blueprints to work here. -# Example: -# pandas==1.3.5