Rework stuff, make user friend, documentation
This commit is contained in:
parent
83cfa15723
commit
26104ecce3
46
README.md
46
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
|
||||
```
|
||||
@ -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
21
requirements.txt
Normal 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
|
||||
@ -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)
|
||||
# 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()
|
||||
Loading…
Reference in New Issue
Block a user