Rework stuff, make user friend, documentation

This commit is contained in:
gregory 2025-03-13 11:19:27 -04:00
parent 83cfa15723
commit 26104ecce3
4 changed files with 190 additions and 55 deletions

View File

@ -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
```

View File

@ -2,5 +2,11 @@
username = username =
password = password =
driver = gecko driver = gecko
output_directory = ./output working_directory = ./tmp
tenant_id = tenant_id =
app_url = https://app.flightschedulepro.com
timeout = 10
[firefox]
binary_path =

21
requirements.txt Normal file
View File

@ -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

View File

@ -8,51 +8,84 @@ from selenium.webdriver.firefox.firefox_profile import FirefoxProfile
from webdriver_manager.firefox import GeckoDriverManager from webdriver_manager.firefox import GeckoDriverManager
from pypdf import PdfWriter from pypdf import PdfWriter
import configparser import configparser
import os import os
import sys
import base64 import base64
import errno import errno
import glob import glob
import argparse
GECKO_DRIVER = 'gecko' GECKO_DRIVER = 'gecko'
CHROME_DRIVER = 'chrome' 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 maybe Ubuntu specific
# HACK
os.environ["TMPDIR"] = "./tmp" 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 = configparser.ConfigParser()
config.read('./config.ini') config.read(args.config)
output_dir = config['main']['output_directory'] working_dir = config['main']['working_directory']
app_url = config['main'].get('app_url', DEFAULT_APP_URL)
timeout = config['main'].get('timeout', DEFAULT_TIMEOUT)
try: try:
os.makedirs(output_dir) os.makedirs(working_dir)
except OSError as exception: 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: if exception.errno != errno.EEXIST:
raise raise
files = glob.glob(os.path.join(working_dir, "*.pdf"))
for f in files:
os.remove(f)
driver_type = config['main']['driver'] driver_type = config['main']['driver']
# TODO only firefox for now # TODO only firefox for now
if driver_type == GECKO_DRIVER: if driver_type == GECKO_DRIVER:
opt = webdriver.FirefoxOptions() opt = webdriver.FirefoxOptions()
opt.binary_location = "/usr/bin/firefox" if config['firefox'].get('binary_path') is not None:
opt.add_argument("-headless") # Here 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 = FirefoxProfile()
firefox_profile.set_preference("print.enabled", False) firefox_profile.set_preference("print.enabled", False)
opt.profile = firefox_profile opt.profile = firefox_profile
driver = webdriver.Firefox(options=opt, service=FirefoxService(GeckoDriverManager().install())) driver = webdriver.Firefox(options=opt, service=FirefoxService(GeckoDriverManager().install()))
else: else:
print(format('unsupported driver type "%s"', driver_type)) print(f'unsupported driver type "{driver_type}"')
os.exit(1) sys.exit(1)
url = 'https://app.flightschedulepro.com/Account/Login?company='+ config['main']['tenant_id'] # optionally prints a message and closes the web driver before exiting
driver.get(url) 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 ¯\_(ツ)_/¯ # wait for stupid cookie modal to load and accept all ¯\_(ツ)_/¯
try: try:
WebDriverWait(driver, 10).until( WebDriverWait(driver, timeout).until(
EC.presence_of_all_elements_located((By.ID, 'onetrust-accept-btn-handler')) EC.presence_of_all_elements_located((By.ID, 'onetrust-accept-btn-handler'))
) )
cookie_btn = driver.find_element(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.send_keys(config['main']['username'])
username.submit() username.submit()
WebDriverWait(driver, 10).until( WebDriverWait(driver, timeout).until(
EC.presence_of_all_elements_located((By.ID, 'password')) EC.presence_of_all_elements_located((By.ID, 'password'))
) )
password = driver.find_element(By.ID, 'password') password = driver.find_element(By.ID, 'password')
@ -75,58 +108,79 @@ password.send_keys(config['main']['password'])
password.submit() password.submit()
# Wait for app to load and naviagte to students "page" # 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')) EC.presence_of_all_elements_located((By.CLASS_NAME, 'fsp-main-drawer-content'))
) )
driver.get('https://app.flightschedulepro.com/App/Students') def get_student_rows():
WebDriverWait(driver, 10).until( driver.get(app_url + '/App/Students')
EC.presence_of_all_elements_located((By.CLASS_NAME, 'clickable-course')) WebDriverWait(driver, timeout).until(
) EC.presence_of_all_elements_located((By.XPATH, '//tr[contains(@class, "clickable-course")]'))
)
# Get all the student rows and iterate # Get all the student rows and iterate
students = driver.find_elements(By.CLASS_NAME, 'clickable-course') student_rows = driver.find_elements(By.XPATH, '//tr[contains(@class, "clickable-course")]')
student_count = len(students) 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(' ', '_') 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() course_td.click()
WebDriverWait(driver, 10).until( WebDriverWait(driver, timeout).until(
EC.presence_of_all_elements_located((By.XPATH, '//span[text()[contains(., "Sessions")]]')) 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 = driver.find_element(By.XPATH, '//span[text()[contains(., "Sessions")]]')
sessions_tab.click() sessions_tab.click()
WebDriverWait(driver, 10).until( WebDriverWait(driver, timeout).until(
EC.presence_of_all_elements_located((By.XPATH, '//fsp-ui-button[@Text="View"]')) 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 # 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 # 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"]') view_btns = driver.find_elements(By.XPATH, '//fsp-ui-button[@Text="View"]')
btn_count = len(view_btns) btn_count = len(view_btns)
pdf_count = 0
print(f'Downloading {btn_count} sessions for student "{student_name}"') # Need to save reference to current window since we will be navigating to the printable document that pops up in another window
for j in range(0, btn_count): 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"]') view_btns = driver.find_elements(By.XPATH, '//fsp-ui-button[@Text="View"]')
if len(view_btns) is not btn_count: if len(view_btns) is not btn_count:
raise 'session count changed. aborting...' die('session count changed. aborting...')
view_btns[j].click() view_btns[i].click()
WebDriverWait(driver, 10).until( WebDriverWait(driver, timeout).until(
EC.presence_of_all_elements_located((By.XPATH, '//fsp-ui-button[@Text="Print"]')) 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 = driver.find_element(By.XPATH, '//fsp-ui-button[@Text="Print"]')
print_btn.click() print_btn.click()
# Wait for the new window or tab # 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 # Loop through until we find a new window handle
for window_handle in driver.window_handles: for window_handle in driver.window_handles:
@ -134,30 +188,38 @@ for i in range(0, student_count):
driver.switch_to.window(window_handle) driver.switch_to.window(window_handle)
break break
# Wait for the new tab to finish loading content # 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 = PrintOptions()
print_options.orientation = "portrait" print_options.orientation = "portrait"
pdf = driver.print_page(print_options) 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'))) file.write(base64.decodebytes(pdf.encode('utf-8')))
# Close the current window
driver.close() driver.close()
# Restore original window
driver.switch_to.window(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 = driver.find_element(By.XPATH, '//fsp-ui-button[@Text="Close"]')
close_btn.click() close_btn.click()
pdf_count = pdf_count + 1
# Merge all Session PDFs for the student # Merge all Session PDFs for the student
print(f'Merging PDFs into "{student_name_no_space}.pdf"') output_path = os.path.join(args.output_dir, student_name_no_space + '.pdf')
driver.close() print(f'Merging PDFs into "{output_path}"')
writer = PdfWriter() 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() pdfs.sort()
# Merge
for pdf in pdfs: 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() writer.close()
die()