def start(): """Setup before initializing curses""" # Handle Signals signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGTSTP, sigtstp_handler) signal.signal(signal.SIGHUP, sighup_handler) # Set a short ESC key delay (curses environment variable) os.environ.setdefault("ESCDELAY", "25") args = parse_args() # Check for updates if not args.no_update and not args.install_version: latest_version = updater.get_latest_version() if latest_version != SharedData.VERSION: updater.update_zygrader(latest_version) sys.exit() if args.install_version: updater.install_version(args.install_version) sys.exit() # Setup user configuration preferences.initialize() versioning.versioning_update_preferences() # Handle configuration based args after config has been initialized handle_args(args) # Check for shared data dir data_dir = preferences.get("data_dir") if not data_dir: print("You have not set the shared data directory") print("Please run with the flag --set-data-dir [path]") sys.exit() # Start application and setup data folders if not SharedData.initialize_shared_data(data_dir): sys.exit() # Load data for the current class data.get_students() data.get_labs() # Change directory to the default output dir os.chdir(os.path.expanduser(preferences.get("output_dir"))) name = data.netid_to_name(getpass.getuser()) # Create a zygrader window, callback to main function ui.Window(main, f"zygrader {SharedData.VERSION}", name, args) logger.log("zygrader exited normally")
def update_preferences(self): theme_key = preferences.get("theme") new_theme = themes.THEMES[theme_key] if new_theme is not self.theme: new_theme.adjust_screen_colors() self.theme = new_theme self.unicode_mode = preferences.get("unicode_mode") self.clear_filter = preferences.get("clear_filter") self.update_window()
def present_queue_errors(self): error_rows = _WorkEvent.queue_errors if not error_rows: return True path_input = ui.layers.PathInputLayer("Queue Data Errors") path_input.set_prompt([ "There were some queue data entries that caused errors.", "Select a file to write them to so that" " you can manually adjust the stats for them.", "", # empty line "If you find common errors, please open an issue on the" " github repo, or talk to a maintainer directly.", "" # empty line ]) default_path = os.path.join(preferences.get("output_dir"), "bad-queue-data.csv") path_input.set_text(default_path) ui.get_window().run_layer(path_input) if path_input.canceled: return False with open(path_input.get_path(), "w", newline="") as out_file: writer = csv.writer(out_file) writer.writerows(error_rows) return True
def report_gaps(): """Report any cells in the gradebook that do not have a score""" window = ui.get_window() if not _confirm_gradebook_ready(): return # Use the Canvas parsing from the gradepuller to get the gradebook in puller = grade_puller.GradePuller() try: puller.read_canvas_csv() except grade_puller.GradePuller.StoppingException: return real_assignment_pattern = re.compile(r".*\([0-9]+\)") # Create mapping from assignment names to lists of students # with no grade for that assignment all_gaps = dict() for assignment in puller.canvas_header: if real_assignment_pattern.match(assignment): gaps = [] for student in puller.canvas_students.values(): if not student[assignment]: gaps.append(student['Student']) if gaps: all_gaps[assignment] = gaps # Abort if no gaps present if not all_gaps: popup = ui.layers.Popup("Full Gradebook", ["There are no gaps in the gradebook"]) window.run_layer(popup) return # Transpose the data for easier reading rows = [list(all_gaps.keys())] added = True while added: added = False new_row = [] for assignment in rows[0]: if all_gaps[assignment]: new_row.append(all_gaps[assignment].pop(0)) added = True else: new_row.append("") rows.append(new_row) # select the output file and write to it out_path = filename_input(purpose="the gap report", text=os.path.join(preferences.get("output_dir"), "gradebook_gaps.csv")) if out_path is None: return with open(out_path, "w", newline="") as out_file: writer = csv.writer(out_file) writer.writerows(rows)
def select_output_file(self): default_output_path = os.path.join(preferences.get("output_dir"), "shaken-stats.csv") path = filename_input(purpose="the shaken stats", text=default_output_path) if path is None: return False self.output_path = path return True
def submission_search_init(): """Get lab part and string from the user for searching""" window = ui.get_window() labs = data.get_labs() menu = ui.layers.ListLayer() menu.set_searchable("Assignment") for lab in labs: menu.add_row_text(str(lab)) window.run_layer(menu, "Submissions Search") if menu.canceled: return assignment = labs[menu.selected_index()] # Select the lab part if needed if len(assignment.parts) > 1: popup = ui.layers.ListLayer("Select Part", popup=True) for part in assignment.parts: popup.add_row_text(part["name"]) window.run_layer(popup, "Submissions Search") if popup.canceled: return part = assignment.parts[popup.selected_index()] else: part = assignment.parts[0] regex_input = ui.layers.BoolPopup("Use Regex") regex_input.set_message(["Would you like to use regex?"]) window.run_layer(regex_input) if regex_input.canceled: return use_regex = regex_input.get_result() text_input = ui.layers.TextInputLayer("Search String") text_input.set_prompt(["Enter a search string"]) window.run_layer(text_input, "Submissions Search") if text_input.canceled: return search_string = text_input.get_text() # Get a valid output path filename_input = ui.layers.PathInputLayer("Output File") filename_input.set_prompt( ["Enter the filename to save the search results"]) filename_input.set_text(preferences.get("output_dir")) window.run_layer(filename_input, "Submissions Search") if filename_input.canceled: return logger = ui.layers.LoggerLayer() logger.set_log_fn(lambda: submission_search_fn( logger, part, search_string, filename_input.get_path(), use_regex)) window.run_layer(logger, "Submission Search")
def main(window: ui.Window, args): """Curses has been initialized, now setup various modules before showing the menu""" # Register preference update callback preferences.add_observer(preference_update_fn) preferences.update_observers() # Notify the user of changes versioning.show_versioning_message(window) # Start file watch thread data.fs_watch.start_fs_watch() # Authenticate the user zybooks_api = zybooks.Zybooks() if not preferences.get("refresh_token") or not user.authenticate( window, zybooks_api): user.login(window) logger.log("zygrader started") mainloop(args)
def view_diff(first: model.Submission, second: model.Submission): """View a diff of the two submissions""" if (first.flag & model.SubmissionFlag.NO_SUBMISSION or second.flag & model.SubmissionFlag.NO_SUBMISSION): window = ui.get_window() popup = ui.layers.Popup("No Submissions", [ "Cannot diff submissions because at least one student has not submitted." ]) window.run_layer(popup) return use_browser = preferences.get("browser_diff") paths_a = utils.get_source_file_paths(first.files_directory) paths_b = utils.get_source_file_paths(second.files_directory) paths_a.sort() paths_b.sort() diff = utils.make_diff_string(paths_a, paths_b, first.student.full_name, second.student.full_name, use_browser) utils.view_string(diff, "submissions.diff", use_browser)
def attendance_score(): """Calculate the participation score from the attendance score columns""" window = ui.get_window() if not _confirm_gradebook_ready(): return # Make use of many functions from gradepuller to avoid code duplication puller = grade_puller.GradePuller() try: puller.read_canvas_csv() popup = ui.layers.Popup("Selection") popup.set_message(["First Select the Participation Score Assignment"]) window.run_layer(popup) participation_score_assignment = puller.select_canvas_assignment() popup.set_message(["Next Select the first Classes Missed Assignment"]) window.run_layer(popup) start_classes_missed_assignment = puller.select_canvas_assignment() popup.set_message(["Next Select the last Classes Missed Assignment"]) window.run_layer(popup) end_classes_missed_assignment = puller.select_canvas_assignment() class_sections = puller.select_class_sections() except grade_puller.GradePuller.StoppingException: return # Get all of the assignments between the start and end start_index = puller.canvas_header.index(start_classes_missed_assignment) end_index = puller.canvas_header.index(end_classes_missed_assignment) all_classes_missed_assignments = puller.canvas_header[ start_index:end_index + 1] # Figure out the grading scheme - the mapping from classes missed to grade builtin_schemes = [ ("TR", [100, 100, 98, 95, 91, 86, 80, 73, 65, 57, 49, 46]), ("MWF", [100, 100, 99, 97, 94, 90, 85, 80, 75, 70, 65, 60, 55, 53]), ] scheme_selector = ui.layers.ListLayer("Scheme Selector", popup=True) for name, scheme in builtin_schemes: scheme_selector.add_row_text( f"{name}: {','.join(map(str,scheme))},...") scheme_selector.add_row_text("Create New Scheme") window.run_layer(scheme_selector) if scheme_selector.canceled: return selected = scheme_selector.selected_index() if selected < len(builtin_schemes): points_by_classes_missed = builtin_schemes[selected][1] else: # Get the custom scheme scheme_inputter = ui.layers.TextInputLayer("New Scheme") scheme_inputter.set_prompt([ "Enter a new scheme as a comma-separated list", "e.g. '100,100,95,90,85,80,78'", "", "The difference between the last two values will be repeated" " until a score of 0 is reached", ]) window.run_layer(scheme_inputter) if scheme_inputter.canceled: return scheme_text = scheme_inputter.get_text() points_by_classes_missed = list(map(int, scheme_text.split(','))) # Extend the scheme until 0 is reached delta = points_by_classes_missed[-2] - points_by_classes_missed[-1] while points_by_classes_missed[-1] >= 0: points_by_classes_missed.append(points_by_classes_missed[-1] - delta) # Get rid of the negative element del points_by_classes_missed[-1] # Calculate and assign the grade for each student for student in puller.canvas_students.values(): if student["section_number"] in class_sections: total_classes_missed = 0 for assignment in all_classes_missed_assignments: try: total_classes_missed += int(student[assignment]) except ValueError: total_classes_missed += int( puller.canvas_points_out_of[assignment]) try: grade = points_by_classes_missed[total_classes_missed] except IndexError: grade = 0 student[participation_score_assignment] = grade out_path = filename_input(purpose="the partipation score", text=os.path.join(preferences.get("output_dir"), "participation.csv")) if out_path is None: return # Again use the gradepuller functionality # We just need to programmatically set the selected assignments puller.selected_assignments = [participation_score_assignment] # And the involved class sections puller.involved_class_sections = set(class_sections) puller.write_upload_file(out_path, restrict_sections=True)
def midterm_mercy(): """Replace the lower of the two midterm scores with the final exam score""" window = ui.get_window() if not _confirm_gradebook_ready(): return # Use the Canvas parsing from the gradepuller to get the gradebook in # also use the selection of canvas assignments from the gradepuller puller = grade_puller.GradePuller() try: puller.read_canvas_csv() popup = ui.layers.Popup("Selection") popup.set_message(["First Select the Midterm 1 Assignment"]) window.run_layer(popup) midterm_1_assignment = puller.select_canvas_assignment() midterm_2_assignment = None double_midterm_popup = ui.layers.BoolPopup("2 Midterms") double_midterm_popup.set_message([ "Is there a second midterm this semester?", "If so, select that assignment next." ]) window.run_layer(double_midterm_popup) if double_midterm_popup.canceled: return if double_midterm_popup.get_result(): midterm_2_assignment = puller.select_canvas_assignment() popup.set_message(["Next Select the Final Exam Assignment"]) window.run_layer(popup) final_exam_assignment = puller.select_canvas_assignment() except grade_puller.GradePuller.StoppingException: return # Do the replacement for each student for student in puller.canvas_students.values(): midterm_1_score = float(student[midterm_1_assignment]) midterm_2_score = (float(student[midterm_2_assignment]) if midterm_2_assignment else None) final_exam_score = float(student[final_exam_assignment]) if midterm_2_assignment: # Figure out lower midterm, then if it should be replaced do so if midterm_2_score < midterm_1_score: if final_exam_score > midterm_2_score: student[midterm_2_assignment] = final_exam_score else: if final_exam_score > midterm_1_score: student[midterm_1_assignment] = final_exam_score else: # With only one midterm, just replace it if lower than final if final_exam_score > midterm_1_score: student[midterm_1_assignment] = final_exam_score out_path = filename_input(purpose="the updated midterm scores", text=os.path.join(preferences.get("output_dir"), "midterm_mercy.csv")) if out_path is None: return # Again use the gradepuller functionality # We just need to programmatically set the selected assignments puller.selected_assignments = [midterm_1_assignment] if midterm_2_assignment: puller.selected_assignments.append(midterm_2_assignment) puller.write_upload_file(out_path) popup = ui.layers.Popup("Reminder") popup.set_message([ "Don't forget to manually correct as necessary" " (for any students who should not have a score replaced)." ]) window.run_layer(popup)
def update_preferences(self): """Update the input settings from user preferences""" self.vim_mode = preferences.get("vim_mode") self.left_right_menu_nav = preferences.get("left_right_arrow_nav") self.use_esc_back = preferences.get("use_esc_back")
def __load_session(self): Zybooks.refresh_token = preferences.get("refresh_token")