diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 69f26d3..4a5bdfb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,6 +11,7 @@ permissions: jobs: lint: + name: Lint Python Code runs-on: ubuntu-latest steps: @@ -21,6 +22,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.14' + cache: 'pip' - name: Install dependencies run: pip install -r helpers/requirements.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..609f658 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Run Python Unit Tests + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Run Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + cache: 'pip' + + - name: Install dependencies + run: pip install -r helpers/requirements.txt + + - name: Run Unit Tests + run: python runtest.py diff --git a/.gitignore b/.gitignore index b9e3cbd..8906a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ -*.xlsx \ No newline at end of file +*.xlsx +!test_output.xlsx \ No newline at end of file diff --git a/documentation.md b/documentation.md index 5da6412..874bf8b 100644 --- a/documentation.md +++ b/documentation.md @@ -2,32 +2,204 @@ The goal of this file is to provide a higher level overview of how this script works to help identify issues, improvements, as well as future maintainability. ## At a Glance There are two primary methods that this script runs through: The CLI version and the GUI version. The CLI version is more or less function complete in how I intend for it to function and operate, though it still has some rough edges, especially in a user interaction factor. The GUI version is what will continue to receive updates and more improvements over time. These two versions can be toggled between in the `config.ini` file. +--- ## main.py This file serves as the starting point for the whole script. If the GUI mode is activated, it will start the GUI from `gui.py`, otherwise, it will take an input from the user and pass it over to `sheetmaker.py` if you're making a new sheet or to `modes.py` if you're using the other features. ## analytics.py Anything related to handling data from Alma gets handled through this script. Between setting up the browser, parsing the HTML, and handling inputs for things such as the SQL. +### get_columns +This function is where all the information related to generating SQL templates is stored. I found it much easier to track things in lists of dictionaries, with each dictionary containing a "Key" value for the section of the database it is searching, as well as "Cols" or columns which stores a list of the values that are listed under the associated "Key" value. Giving this function different values while calling returns different templates for different purposes. Due to how some listings do not appear while some columns are present, some of these are broken up into really small pieces. +### get_col_len +Returns the total number of columns within the SQL template. This helps with tracking how many columns need to be searched across while reading the tables within Alma analytics. +### get_table +Handles reading and parsing out the table information from the Alma screen. +### pull_one_search +Takes a list of MMS IDs and returns a list of the same size of `True` or `False`. False indicates that NO OneSearch listings appeared under the MMS ID search, while True indicates that something did return. This aids in finding out which MMS IDs and listings are still in circulation. +### pull_data +Given some data, this function automates creating a SQL statement and pulling data from the database. +### input_sql +Automates opening the SQL input window in Alma and inputting the given text. +### check_element +Aids in automating checking whether a given HTML element is present on the page, to know when it is okay to move to the next step. +### click_element +A template for telling the web driver what to click given some details about the specific HTML element. +### process_new_isbn +UNFINISHED and UNUSED; This function would ideally be used to find what ISBNs we do own for particular books from the bookstore, where a different ISBN is provided. +### process_analytics +Function that interacts with the other scripts to aid in pulling the analytics data given the web driver. Contains all the logic for how to process each set of information. +### setup_analytics +Creates and sets up the web browser to be prepared for automation tasks. +### setup_sql +Given a set of keys and columns to read, creates a complete SQL statement to use. +### export_analytics +Exports the data to a given csv file path. +### import_analytics +Imports the data from the given csv file path. + ## bookstore.py Most of this code is pulled from the original bookstore data puller that was made to automate getting bookstore data. It will have the user pass a CAPTCHA and then have them select data to pull from to get the specific term of textbook data they are interested in. +### str_clean +Removes certain special characters and whitespace from strings, aids in standardizing book titles. +### get_page_soup +Uses the BS4 library to grab a given URL, take the HTML contents and turn it into a workable form of data. +### get_link +Helper function to reduce number of replace statements later, simply helps create a link to use for price comparisons. +### get_prices +UNFINISHED and UNUSED; If there were to be a cost analysis function, this is what it would be, but the volume of books makes it a little too difficult to query this webserver as much as we would like to so it remains commented out. +### pull_textbook_data +The master information pulling function for the bookstore data. +### pull_info +Imports data from the bookstore csv file, mainly used by other parts of the program. + ## classes.py Stores the frameworks and methods for interacting with storing and processing book related data. +### Book (Class) +- Contstructor +Creating an object requires a dictionary of a couple different values: Related course, section, instructor name and email, enrollment information, the ISBN, book title, author, edition, publisher, requirements state, requisition date, the bookstore comment, and the Alma Analytics data for the given book. The constructor will parse out the information to make it more easily accessible for processing later. +- add_course +When another entry for a book is found that already exists as an object, the new course information gets added via this function. +- add_section +Similar to the course entry, but instead adding a section to an existing course within an existing book. +- add_isbn +Adding ISBN values for different variants of the same book. +- add_enroll +Putting in additional enrollment information into the book to track all the campuses and possible enrollment values. +- add_required +If at any point a bookstore listing has the book as "Required", it sets the book to that status using this function. +### get_max_index +Finds the index of the course with the most sections within it in order to aid in reducing column count, as well as ensuring the largest courses are always first (leftmost). +### get_max_courses +Gets the total number of course sections to have (the number of courses the book with the most courses has). +### get_max_sections_list +Finds the number of sections needed for each course section. As an example, if we have two books, one book has 3 course with 10, 5, and 3 sections respectively ([10, 5, 3]), the other has 2 courses, one with 7 sections, the other with 6 sections ([7, 6]), then this function finds we need Course 1 to have 10 section slots, Course 2 to have 6, and Course 3 to have 3. +### process_book +Takes the preset information about a book and checks against all books so far to ensure that it does not already exist, otherwise it adds it to the master list. +### process_courses +Uses the prior get_max_courses function in order to create the headers and add them to the dataframe. +### process_sections +Uses the prior get_max_sections_list function in order to create the individual section headers and adds them to the dataframe. +### process_isbns +This function both finds the max number of ISBN columns required and puts the necessary columns into the dataframe. +### import_data +Imports all the data from the master book object list and imports it into the dataframe once it is ready to go. + ## emails.py Handles compiling and creating Excel sheets for PowerAutomate emails. +### Book (Class) +This class is a very tiny version of the other class that just takes in some basic information for the purposes of emails. +- Constructor +Takes the title, author, edition, year published, and access information. +### Instructor (Class) +- Constructor +Takes in the instructor name, email, as well as course, section, and book information per section. +- add_book +Adds books to a given course section for each professor. +### update_excel +Updates the main sheet with marking off what emails have been successfully created. +### create_email_excel +Processes all the Instructor data to create the table to be used to write it into an Excel sheet. +### write_to_excel +Writes the email data to an Excel sheet to be used with PowerAutomate. + ## enrollment.py Handles pulling the relevant data from the enrollment csv file in order to get the maximum enrollment values. +### get_enrollment_data +Processes the format of the given CORE report enrollment file, returning campus and enrollment information. + ## grabber.py Deals with setting up and handling pulling data from the Outlook browser to get emails for individual professors. +### process_name +### process_suggestion +### get_email +### setup_grabber +### grabber_gui +- set_email_store +- run_process_suggestion +- run_check_ui +- run_check_web +- run_suggestion_ui +- run_get_email +### email_importer +### email_exporter + ## gui.py This hosts all of the primary interactive GUI for the user. +### GUI (Class) +- Constructor +- reset_main +- print_main +- build_main +- build_emails +- build_headers +- build_advanced +- build_sheet_outlook +- build_sheet_alma +- build_sheet_final +- build_import_csv +- start_analytics_csv +- start_bookstore_csv +- start_grabber_csv +- start_mode +- write_cfg +- write_headers +- write_emails +### start_app + ## helpergui.py This is a much slimmer and simpler version of the main GUI class, to be something much more modular and additive. +### AddedGUI (Class) +- Constructor +- reset +- add_label +- add_button +### make_window + ## modes.py Aids in handling the various functions of the script, modularizing individual pieces into useful functions. +### start_mode +### csv_mode +### email_mode +### update_mode +### emails_csv +### analytics_csv +### enrollment_update +### analytics_update +### emails_update +### get_import + ## output.py -This script has a single function: outputting an Excel sheet! +This script has a single function: outputting an Excel sheet! This is where formatting and such gets handled (i.e. color, column sizing, etc.) +### write_to_sheet + ## sheetmaker.py Performs all of the functions related to creating new sheets from scratch. +### make_excel_sheet + ## utilties.py This script hosts helpful functions that might be purposeful in multiple places around the various helper and main scripts. On top of this, it also helps to host hard coded data that is not necessary to keep in a configuration file (such as header values!). The organization is to help cut down on lines of code in other places, as well as keep information consistency so updating one variable does update it in all relevant places when needed. +### get_int +Takes a value, gets an integer out of it if it can, otherwise returns None. +### get_clean +Removes preset phrases and terms from the names of books and authors to reduce the number of duplicate listings. +### get_state +Takes strings of "True" or "False" and converts them to bool values. +### get_directory +### get_filepath +### get_letter +### get_edition_string +### get_format_headers +### get_replace_header +### get_split_course +### get_input +### get_enabled +### get_sheet_headers +### get_config_headers +### get_string_cleaners +### get_row_info +### get_campus +### set_col_format +A +--- ## CSV Storage In order to store all the data in a way that is accessible, quick, and aids in subsequent run times, all pulled data is compiled into `.csv` files, each with their own format. This makes it so we don't have to re-run the bookstore scraper, email grabber, or analytics scraper again every single time we wish to do something. ### analytics.csv @@ -47,6 +219,7 @@ Data is formatted as following: Instructor Name, Email ### enrollment.csv This is just a CORE report exported as a csv for all courses in the desired term. This must be done outside of the script itself. +--- ## Config Files ### config.ini Primary settings for the script, though some of these are redundant / only used by one half of the script. diff --git a/helpers/analytics.py b/helpers/analytics.py index b68303c..8b3ba3b 100644 --- a/helpers/analytics.py +++ b/helpers/analytics.py @@ -12,82 +12,6 @@ import ast import threading -""" -SELECT "Bibliographic Details"."Author" saw_0, -"Bibliographic Details"."Earliest Possible Publication Year" saw_1, -"Bibliographic Details"."Title" saw_2, -"Bibliographic Details"."Publisher" saw_3, -"Bibliographic Details"."MMS Id" saw_4, -"Bibliographic Details"."ISBN" saw_5, -"Bibliographic Details"."Edition" saw_6, -"Bibliographic Details"."Material Type" saw_7, -"Bibliographic Details"."Resource Type" saw_8, -"Edition Simplified"."Edition Simplified (Num)" saw_9, -FROM "Digital Inventory" -WHERE -"Bibliographic Details"."ISBN" LIKE '%9781478651123%' -""" - -""" -SELECT "Bibliographic Details"."Author" saw_0, -"Bibliographic Details"."Earliest Possible Publication Year" saw_1, -"Bibliographic Details"."Title" saw_2, -"Bibliographic Details"."Publisher" saw_3, -"Bibliographic Details"."MMS Id" saw_4, -"Bibliographic Details"."ISBN" saw_5, -"Bibliographic Details"."Edition" saw_6, -"Bibliographic Details"."Material Type" saw_7, -"Bibliographic Details"."Resource Type" saw_8, -"Representation Access Rights"."Access Right Name" saw_9, -"Representation Access Rights"."Access Right Desc" saw_10, -"Edition Simplified"."Edition Simplified (Num)" saw_11, -FROM "Digital Inventory" WHERE "Bibliographic Details"."ISBN" LIKE '%' -""" - -""" -SELECT "Bibliographic Details"."Author" saw_0, -"Bibliographic Details"."Earliest Possible Publication Year" saw_1, -"Bibliographic Details"."Title" saw_2, -"Bibliographic Details"."Publisher" saw_3, -"Bibliographic Details"."MMS Id" saw_4, -"Bibliographic Details"."ISBN" saw_5, -"Bibliographic Details"."Edition" saw_6, -"Bibliographic Details"."Material Type" saw_7, -"Bibliographic Details"."Resource Type" saw_8, -"Representation Access Rights"."Access Right Name" saw_9, -"Representation Access Rights"."Access Right Desc" saw_10, -"Edition Simplified"."Edition Simplified (Num)" saw_11, -FROM "Digital Inventory" WHERE UPPER("Bibliographic Details"."TITLE") LIKE UPPER('%') -""" - -""" -SELECT - "Bibliographic Details"."Title" saw_0, - "Vendor Interface"."Vendor Name" saw_1, - "Vendor Interface"."Interface Name" saw_2, - "-- Bibliographic Details"."MMS Id" saw_3, - "Vendor Interface"."Available" saw_4 - FROM "E-Inventory" - WHERE -UPPER("Bibliographic Details"."TITLE") LIKE UPPER('%CLIMATE CASINO%') -""" - -# TODO -# fixing missing isbn or mismatched values rom the bookstore -# if nothing returns, looking up by the book title instead, comparing -# author, publisher and edition number -# use new isbn and information to backpush updating the book information - -# can search by isbn but needs to be without restrictive filters -# then needs to add on the license filters, log where necessary -# if nothing is found at ISBN, then needs to pivot to searching for book -# still store data at old ISBN when exporting to CSV, but inlude alternative -# ISBN information..? and then reinclude additional ISBN if not in original sheet? - -# new solution : E-Inventory ? -# this should correct any problems and now it should be feasible to take out the representation -# access right sql queries, which will make it easier to access the correct information - def get_columns(key="ebook"): """Gets the columns being used to construct the SQL query.""" @@ -113,7 +37,7 @@ def get_columns(key="ebook"): ] overall_section = "Digital Inventory" - if key == "access": + elif key == "access": sql_columns = [ { "Key": "Bibliographic Details", @@ -209,12 +133,7 @@ def get_table(driver): # TODO -# this needs SEVERE checking -# first thought is the search link could possibly be incorrect? -# maybe searching via MMS ID is improper? -# patterns kristin told me: -# NONE of the Ebooks were caught, ONLY CDLs, but not ALL CDLs -# Print books were ONLY from our MAIN collection +# may need further testing def pull_one_search(driver, mms_list): """Opens a OneSearch tab to double check that the MMS ID exists within Primo. Takes a list and returns a list, though only used with single IDs right now.""" @@ -255,7 +174,8 @@ def pull_one_search(driver, mms_list): def pull_data(driver, bib_section, bib_value, sql_key): - """""" + """Given a section, value, and specific SQL template, grabs the + relevant data that is desired.""" section, sql_cols = get_columns(key=f"{sql_key}") sql = setup_sql(section, sql_cols, f"{bib_section}") sql = sql.replace("%", f"%{bib_value}%") @@ -298,60 +218,6 @@ def pull_data(driver, bib_section, bib_value, sql_key): return return_list -def pull_ebook_access(driver, mms_id): - """""" - section, sql_cols = get_columns(key="access") - sql = setup_sql(section, sql_cols, "MMS Id") - sql = sql.replace("%", f"%{mms_id}%") - - -def pull_analytics(driver, isbn_list, state): - """Pulls the analytics information for the given ISBN list from the table.""" - return_list = [] - sql_section, sql_columns = get_columns(state) - sql_statement = setup_sql(sql_section, sql_columns) - - for isbn in isbn_list: - - sql_text = sql_statement.replace("%", f"%{isbn}%") - input_sql(driver, sql_text) - tr_list = get_table(driver) - - if tr_list == []: - return return_list - - cutoff = 3 + get_col_len(sql_columns) - tr_list = tr_list[cutoff:] - - # storing entries for empty portions - store_dict = {} - # storing order for indexing - store_list = [] - for dict in sql_columns: - for col in dict["Cols"]: - store_dict[col] = "" - store_list.append(col) - - for tr in tr_list: - td_list = tr.find_all("td") - for td in td_list: - try: - td_id = td["id"] - td_text = td.get_text() - id_list = td_id.split("_") - cat_id = get_int(id_list[5]) - column = store_list[cat_id] - store_dict[column] = td_text - except Exception as err: - print(err) - - return_list.append(store_dict.copy()) - - # print(f"{isbn_list} --> {return_list}") - - return return_list - - def input_sql(driver, text=""): """Helper function that inputs the given text into the SQL input box.""" click_element(driver, "td", "title", "Edit SQL, XML and other technical details") @@ -401,20 +267,6 @@ def click_element(driver, tag, selector, detail): print(err) -def setup_sql(sql_section, sql_list, bib_section="ISBN"): - """Helper function to turn the SQL categories and lists into a full single query.""" - sql = "SELECT" - idx = 0 - for dict in sql_list: - section = dict["Key"] - for col in dict["Cols"]: - sql += f' "{section}"."{col}" saw_{idx},' - idx += 1 - sql += f' FROM "{sql_section}" WHERE "Bibliographic Details"."{bib_section}"' - sql += """ LIKE '%' """ - return sql - - def process_new_isbn(driver, title, state): """Helper function to find what ISBN we do own for a particular book.""" check_list = pull_one_search(driver, [title]) @@ -474,13 +326,6 @@ def process_new_isbn(driver, title, state): return None -# TODO -# TEST WITH 'CLIMATE CASINO' AS THE TITLE -# CANNOT DO DIGITAL INVENTORY WITH THE LICENSE TYPES DUE TO PHYSICAL -# need to work around the digital license problem first ! -# rework this to reprocess the digitial poritions first -# and then ensure it is checking for Book - Electronic first -# or something similar def process_analytics(analytics_driver, isbn): """Fully processes a given ISBN number and pulling all the data from Alma + processing it.""" year = None @@ -508,8 +353,6 @@ def process_analytics(analytics_driver, isbn): if mms_id.strip() == "": continue - # TODO - # double check implementation of this one search checker check_list = pull_one_search(analytics_driver, [mms_id]) if check_list[0] is False: print( @@ -578,8 +421,6 @@ def process_analytics(analytics_driver, isbn): if mms_id.strip() == "": continue - # TODO - # double check implementation of this one search checker check_list = pull_one_search(analytics_driver, [mms_id]) if check_list[0] is False: print(f"Missing from Primo (EBOOK); skipping. {mms_id}") @@ -633,6 +474,11 @@ def process_analytics(analytics_driver, isbn): for section in process_list: if "user".upper() in section: users = get_int(section.split(" ")[0]) + + # adding a failstate to ensure these values are marked as unknown + # and can be easily removed via find & replace + if users == -1: + users = "?" if mms_id not in data: data[mms_id] = { @@ -732,6 +578,20 @@ def context_window(text_list): return driver +def setup_sql(sql_section, sql_list, bib_section="ISBN"): + """Helper function to turn the SQL categories and lists into a full single query.""" + sql = "SELECT" + idx = 0 + for dict in sql_list: + section = dict["Key"] + for col in dict["Cols"]: + sql += f' "{section}"."{col}" saw_{idx},' + idx += 1 + sql += f' FROM "{sql_section}" WHERE "Bibliographic Details"."{bib_section}"' + sql += """ LIKE '%' """ + return sql + + def export_analytics(path, info): """Exports the analytics data into a CSV.""" with open(path, "w", newline="") as csvfile: diff --git a/helpers/bookstore.py b/helpers/bookstore.py index c1279b9..c0f849d 100644 --- a/helpers/bookstore.py +++ b/helpers/bookstore.py @@ -11,12 +11,21 @@ from helpers.helpergui import AddedGUI -def str_clean(str): +def str_clean(value): """Removes special characters and whitespace from a string.""" - new_str = str.replace(" ", "") - new_str = new_str.replace(" ", "") - new_str = new_str.replace("\t", "") - new_str = new_str.replace("\n", "") + original = value + new_str = "" + first = True + while new_str is not original: + if first: + first = False + else: + original = new_str + new_str = original.strip() + new_str = new_str.replace(" ", "") + new_str = new_str.replace(" ", " ") + new_str = new_str.replace("\t", "") + new_str = new_str.replace("\n", "") return new_str @@ -116,6 +125,8 @@ def get_prices(driver, table): main_div = material_dict[isbn] main_div.click() # TODO + # this has been partially implemented but it cannot handle the number of + # requests this script would like to make, check other TODO or documentation # this needs to pull the used and new price points from the beaverstore page # https://osubeaverstore.verbacompare.com/comparison?id=2026-Summer__AEC__411__400 html = main_div.get_attribute("innerHTML") @@ -294,7 +305,7 @@ def set_term_val(value): if row_skip == 0: # if at some point it would be desirable to add in book pricing # this function doesn't work as is, but i stopped progress on it - # seeing has the compare site has rate limiting, which is fair + # seeing as the compare site has rate limiting, which is fair # otherwise, it would be best to simply link to the compare site # in the sheet if anything i think # row_text.append(STORE LINK HERE) diff --git a/helpers/classes.py b/helpers/classes.py index 4376bc5..d046976 100644 --- a/helpers/classes.py +++ b/helpers/classes.py @@ -6,7 +6,7 @@ class Book: """Object that stores relevant book information to be used to format and configure a dataframe. Takes in a dictionary to construct the base - object. See main.py for the book_info dictionary structure.""" + object. See sheetmaker.py for the book_info dictionary structure.""" def __init__(self, info): # bookstore information @@ -57,7 +57,10 @@ def add_course(self, course, section, instructor, email, enroll): def add_section(self, course, section, instructor, email, enroll): """Adds section information to book object.""" - idx = self.courses.index(course) + try: + idx = self.courses.index(course) + except ValueError as err: + print(f"Course Index not found: {err}") self.sections[idx].append([section, instructor, email, enroll[0]]) self.sec_size[idx] += 1 self.add_enroll(enroll[1], enroll[0]) @@ -87,11 +90,13 @@ def add_required(self): def get_max_index(len_list): - """Gets the index of the largest course based on number of sections.""" + """Gets a list of indices of the largest courses in order based on number of sections.""" output = [] temp = len_list.copy() while max(temp) > 0: + # append the index of the largest course output.append(temp.index(max(temp))) + # set that course size to 0 before going to the next temp[output[-1]] = 0 return output @@ -105,7 +110,7 @@ def get_max_courses(book_list): def get_max_sections_list(book_list, course_amt): - """Gets the list of section counts by course for a book.""" + """Gets the values for how many section columns to put per course numbering.""" sec_len = [] for idx in range(course_amt): sec_len.append(0) @@ -120,7 +125,7 @@ def get_max_sections_list(book_list, course_amt): def process_book(book_list, book_dict): - """Processed incoming book information and crates / adds book + """Processed incoming book information and creates / adds book information to the main list of book objects.""" book = None for search_book in book_list: diff --git a/helpers/emails.py b/helpers/emails.py index 50e310f..1a7fe17 100644 --- a/helpers/emails.py +++ b/helpers/emails.py @@ -30,6 +30,9 @@ def __init__(self, name, email, course, section, book): self.data = {course: {section: [book]}} def add_book(self, course, section, book): + # if we have the course & section, add book to it + # if we have course, but not section, make new section with book + # if neither, make new course and section dictionary if course in self.data and section in self.data[course]: self.data[course][section].append(book) elif course in self.data: @@ -58,27 +61,19 @@ def update_excel(directory, data, sheet_name): cell.value = datetime.datetime.now() break workbook.save(directory) + workbook.close() -def write_to_excel(directory, export_data, sheetname): - """Exports given data to given directory with given sheetname.""" - dataframe = pd.DataFrame(data=export_data) - writer = pd.ExcelWriter(directory, engine="xlsxwriter") - dataframe.to_excel( - writer, sheet_name=sheetname, startrow=1, header=False, index=False - ) - worksheet = writer.sheets[sheetname] - max_row, max_col = dataframe.shape - column_settings = [] - for header in dataframe.columns: - column_settings.append({"header": header}) - worksheet.add_table(0, 0, max_row, max_col - 1, {"columns": column_settings}) - worksheet.set_column(0, max_col - 1, 12) - writer.close() +# the goal here should be to load book data into a book object, +# then for each instance of an instructor for that book, +# assign the book reference to them with the course + section +# if instructor already exists, just add the other information +# otherwise, make new instructor # TODO # rewrite this into smaller functions to break it apart a little +# could easily be much more functionized, making it easier to work with def create_email_excel(input_sheet=None, file_name=""): """Creates an excel sheet to be formatted for usage with PowerAutomate function.""" full_config = configparser.ConfigParser() @@ -276,7 +271,8 @@ def create_email_excel(input_sheet=None, file_name=""): # course[1]}.