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 =
password =
driver = gecko
output_directory = ./output
working_directory = ./tmp
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

@ -9,50 +9,83 @@ from webdriver_manager.firefox import GeckoDriverManager
from pypdf import PdfWriter
import configparser
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)
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()