From 26104ecce3bc020e31b291590042e70c35a5c54b Mon Sep 17 00:00:00 2001 From: gregory Date: Thu, 13 Mar 2025 11:19:27 -0400 Subject: [PATCH] Rework stuff, make user friend, documentation --- README.md | 46 +++++++++++ config.ini | 10 ++- requirements.txt | 21 +++++ save_progress_reports.py | 168 +++++++++++++++++++++++++++------------ 4 files changed, 190 insertions(+), 55 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index e69de29..98690a8 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,46 @@ +# Flight Schedule Pro Scripts + +## Requirement +Python 3 +Pip +Firefox + +## Install + +Clone this repository +```bash +#navigate to the folder +cd fsp_scripts +#create a virtual environment and install dependencies +python -m vene .venv +source .venv/bin/activate +pip install -r requirements.txt +``` +Update config.ini with you Flight Schedule Pro username and password + +## Config + +TODO + +## Scripts + + +### save_progress_reports.py + +Downloads individual session PDFs for students and merges them into a single PDF per student + +#### Usage + +```bash +#At a minimum the script requires one argument, the name of a student to retrieve sessions for +python save_progress_reports.py "Greg Johnson" + +#Also accepts multiple students +python save_progress_reports.py "Greg Johnson" "Casey Serbagi" + +#Partial names work as well (retrieves the first matching student name found) +python save_progress_reports.py Greg + +#More usage details +python save_progress_reports.py --help +``` diff --git a/config.ini b/config.ini index 06c5b26..179d05e 100644 --- a/config.ini +++ b/config.ini @@ -2,5 +2,11 @@ username = password = driver = gecko -output_directory = ./output -tenant_id = \ No newline at end of file +working_directory = ./tmp +tenant_id = +app_url = https://app.flightschedulepro.com +timeout = 10 + + +[firefox] +binary_path = \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..15e7b41 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +attrs==25.2.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +h11==0.14.0 +idna==3.10 +outcome==1.3.0.post0 +packaging==24.2 +pypdf==5.3.1 +PySocks==1.7.1 +python-dotenv==1.0.1 +requests==2.32.3 +selenium==4.29.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +trio==0.29.0 +trio-websocket==0.12.2 +typing_extensions==4.12.2 +urllib3==2.3.0 +webdriver-manager==4.0.2 +websocket-client==1.8.0 +wsproto==1.2.0 diff --git a/save_progress_reports.py b/save_progress_reports.py index 1dac0a1..b437ad6 100644 --- a/save_progress_reports.py +++ b/save_progress_reports.py @@ -8,51 +8,84 @@ from selenium.webdriver.firefox.firefox_profile import FirefoxProfile from webdriver_manager.firefox import GeckoDriverManager from pypdf import PdfWriter import configparser -import os +import os +import sys import base64 import errno import glob +import argparse GECKO_DRIVER = 'gecko' CHROME_DRIVER = 'chrome' +DEFAULT_CONFIG_PATH = './config.ini' +DEFAULT_OUTPUT_PATH = './' +DEFAULT_APP_URL = 'https://app.flightschedulepro.com' +DEFAULT_TIMEOUT = 10 #seconds +DEFAULT_MAX_SESSIONS = 500 - -# HACK +# HACK maybe Ubuntu specific os.environ["TMPDIR"] = "./tmp" +parser = argparse.ArgumentParser( + prog='FSP Progress Combiner', + description='Retrieves progress reports for a given student and combines them into a single PDF', + epilog='Kill urself') +parser.add_argument('-c', '--config', default=DEFAULT_CONFIG_PATH) +parser.add_argument('-o', '--output_dir', default=DEFAULT_OUTPUT_PATH) +parser.add_argument('students', nargs='*', help='Name of the student(s) to retrieve sessions for. If a partial name is provided, the first student with a matching name will be retrieved (case insensitive)') +# Useful for debugging +parser.add_argument('-m', '--maximum_sessions', default=DEFAULT_MAX_SESSIONS, type=int, help='Limits the number of sessions retrieved per student') +parser.add_argument('-l', '--list_students', help='Outputs names all students', action='store_true') +parser.add_argument('-s', '--show_browser', help='Show browser window', action='store_true') + +args = parser.parse_args() + config = configparser.ConfigParser() -config.read('./config.ini') -output_dir = config['main']['output_directory'] +config.read(args.config) +working_dir = config['main']['working_directory'] +app_url = config['main'].get('app_url', DEFAULT_APP_URL) +timeout = config['main'].get('timeout', DEFAULT_TIMEOUT) + try: - os.makedirs(output_dir) + os.makedirs(working_dir) except OSError as exception: - files = glob.glob(os.path.join(output_dir, "*.pdf")) - for f in files: - os.remove(f) if exception.errno != errno.EEXIST: raise + files = glob.glob(os.path.join(working_dir, "*.pdf")) + for f in files: + os.remove(f) driver_type = config['main']['driver'] # TODO only firefox for now if driver_type == GECKO_DRIVER: opt = webdriver.FirefoxOptions() - opt.binary_location = "/usr/bin/firefox" - opt.add_argument("-headless") # Here + if config['firefox'].get('binary_path') is not None: + opt.binary_location = config['firefox'].get('binary_path') + if not args.show_browser: + opt.add_argument('-headless') + # Disable print dialog. Causes race conditions with window switching firefox_profile = FirefoxProfile() firefox_profile.set_preference("print.enabled", False) opt.profile = firefox_profile driver = webdriver.Firefox(options=opt, service=FirefoxService(GeckoDriverManager().install())) else: - print(format('unsupported driver type "%s"', driver_type)) - os.exit(1) + print(f'unsupported driver type "{driver_type}"') + sys.exit(1) -url = 'https://app.flightschedulepro.com/Account/Login?company='+ config['main']['tenant_id'] -driver.get(url) +# optionally prints a message and closes the web driver before exiting +def die(message=None): + if message is not None: + print(message) + driver.close() + sys.exit(0) + +login_url = app_url + '/Account/Login?company='+ config['main']['tenant_id'] +driver.get(login_url) # wait for stupid cookie modal to load and accept all ¯\_(ツ)_/¯ try: - WebDriverWait(driver, 10).until( + WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located((By.ID, 'onetrust-accept-btn-handler')) ) cookie_btn = driver.find_element(By.ID, 'onetrust-accept-btn-handler') @@ -66,7 +99,7 @@ username = driver.find_element(By.ID, 'username') username.send_keys(config['main']['username']) username.submit() -WebDriverWait(driver, 10).until( +WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located((By.ID, 'password')) ) password = driver.find_element(By.ID, 'password') @@ -75,58 +108,79 @@ password.send_keys(config['main']['password']) password.submit() # Wait for app to load and naviagte to students "page" -WebDriverWait(driver, 10).until( +WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located((By.CLASS_NAME, 'fsp-main-drawer-content')) ) -driver.get('https://app.flightschedulepro.com/App/Students') -WebDriverWait(driver, 10).until( - EC.presence_of_all_elements_located((By.CLASS_NAME, 'clickable-course')) -) +def get_student_rows(): + driver.get(app_url + '/App/Students') + WebDriverWait(driver, timeout).until( + EC.presence_of_all_elements_located((By.XPATH, '//tr[contains(@class, "clickable-course")]')) + ) -# Get all the student rows and iterate -students = driver.find_elements(By.CLASS_NAME, 'clickable-course') -student_count = len(students) + # Get all the student rows and iterate + student_rows = driver.find_elements(By.XPATH, '//tr[contains(@class, "clickable-course")]') + return student_rows -print(f'processing {student_count} students') +# Just list the students if -l switch is passed in +if args.list_students: + student_rows = get_student_rows() + for row in student_rows: + student_name = row.find_element(By.XPATH, './td/div[@class="student"]/div[@class="bold"]').get_attribute("innerText") + print(student_name) + die() + +for student in args.students: + student_rows = get_student_rows() + # Iterate over the student rows to find the desired student + target = None + for student_row in student_rows: + student_name = student_row.find_element(By.XPATH, './td/div[@class="student"]/div[@class="bold"]').get_attribute("innerText") + if student_name.lower().startswith(student.lower()): + print(f'Found matching student name "{student_name}"') + target = student_row + break + if target is None: + print(f'No student with a name matching "{student}" found') + continue -original_window = driver.current_window_handle -for i in range(0, student_count): - # Need to reload student elements after returning to page - students = driver.find_elements(By.CLASS_NAME, 'clickable-course') - if len(students) is not student_count: - raise 'student count changed. aborting...' - student = students[i] - student_name = student.find_element(By.XPATH, '//div[@class="student"]/div[@class="bold"]').get_attribute("innerText") student_name_no_space = student_name.replace(' ', '_') - course_td = student.find_element(By.CLASS_NAME, 'course-td') + + course_td = target.find_element(By.CLASS_NAME, 'course-td') course_td.click() - WebDriverWait(driver, 10).until( + WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located((By.XPATH, '//span[text()[contains(., "Sessions")]]')) ) sessions_tab = driver.find_element(By.XPATH, '//span[text()[contains(., "Sessions")]]') sessions_tab.click() - WebDriverWait(driver, 10).until( + WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located((By.XPATH, '//fsp-ui-button[@Text="View"]')) ) # Ugh... Sessions Table reloads on modal opening after the first modal is opened. So we have to record # the count of sessions initially and iterate while reloading the elements each time a button is clicked view_btns = driver.find_elements(By.XPATH, '//fsp-ui-button[@Text="View"]') btn_count = len(view_btns) - pdf_count = 0 - print(f'Downloading {btn_count} sessions for student "{student_name}"') - for j in range(0, btn_count): + + # Need to save reference to current window since we will be navigating to the printable document that pops up in another window + original_window = driver.current_window_handle + for i in range(0, btn_count): + if i + 1 > args.maximum_sessions: + print('Reached max sessions') + break + + print(f'Downloading session {i+1} of {btn_count} for student "{student_name}"') + # DOM probably reloaded so existing view_btn elements are stale view_btns = driver.find_elements(By.XPATH, '//fsp-ui-button[@Text="View"]') if len(view_btns) is not btn_count: - raise 'session count changed. aborting...' - view_btns[j].click() - WebDriverWait(driver, 10).until( + die('session count changed. aborting...') + view_btns[i].click() + WebDriverWait(driver, timeout).until( EC.presence_of_all_elements_located((By.XPATH, '//fsp-ui-button[@Text="Print"]')) ) print_btn = driver.find_element(By.XPATH, '//fsp-ui-button[@Text="Print"]') print_btn.click() # Wait for the new window or tab - WebDriverWait(driver, 10).until(EC.number_of_windows_to_be(2)) + WebDriverWait(driver, timeout).until(EC.number_of_windows_to_be(2)) # Loop through until we find a new window handle for window_handle in driver.window_handles: @@ -134,30 +188,38 @@ for i in range(0, student_count): driver.switch_to.window(window_handle) break # Wait for the new tab to finish loading content - WebDriverWait(driver, 10).until(EC.title_is('Print Training Session')) + WebDriverWait(driver, timeout).until(EC.title_is('Print Training Session')) + + # Get base64 representation of pdf of the page and write to file print_options = PrintOptions() print_options.orientation = "portrait" pdf = driver.print_page(print_options) - with open(os.path.join(output_dir, student_name_no_space + "{:03d}".format(pdf_count) + '.pdf'), 'wb') as file: + with open(os.path.join(working_dir, student_name_no_space + "{:03d}".format(i) + '.pdf'), 'wb') as file: file.write(base64.decodebytes(pdf.encode('utf-8'))) + + # Close the current window driver.close() + # Restore original window driver.switch_to.window(original_window) - WebDriverWait(driver, 10).until(EC.title_is('Flight Schedule Pro')) + WebDriverWait(driver, timeout).until(EC.title_is('Flight Schedule Pro')) close_btn = driver.find_element(By.XPATH, '//fsp-ui-button[@Text="Close"]') close_btn.click() - pdf_count = pdf_count + 1 + # Merge all Session PDFs for the student - print(f'Merging PDFs into "{student_name_no_space}.pdf"') - driver.close() + output_path = os.path.join(args.output_dir, student_name_no_space + '.pdf') + print(f'Merging PDFs into "{output_path}"') writer = PdfWriter() - pdfs = [a for a in os.listdir(output_dir) if a.endswith(".pdf") and a.startswith(student_name_no_space)] + # Get all the idividual sessions PDFs for this student + pdfs = [a for a in os.listdir(working_dir) if a.startswith(student_name_no_space) and a.endswith('.pdf')] pdfs.sort() + # Merge for pdf in pdfs: - writer.append(os.path.join(output_dir, pdf)) + writer.append(os.path.join(working_dir, pdf)) - writer.write(student_name_no_space + '.pdf') + writer.write(output_path) writer.close() +die() \ No newline at end of file