def create_canvas_assignments(self) -> 'Course': """Create assignments in Canvas. :returns: The course object to allow for method chaining. :rtype: Course """ # PRINT BANNER print(utils.banner('Creating/updating assignments in Canvas')) # Initialize status table that we can push to as we go assignment_header = [['Assignment', 'Action']] assignment_status = [] for assignment in tqdm(self.assignments): status = assignment.update_or_create_canvas_assignment() assignment_status.append([assignment.name, status]) # Sort statuses assignment_status = sorted(assignment_status, key=lambda k: k[0]) ## Print status for reporting: table = AsciiTable(assignment_header + assignment_status) table.title = 'Assignments' print("\n") print(table.table) print("\n") return self
def get_students_from_canvas(self) -> 'Course': """Get the course student list from Canvas. :returns: The course object to allow for method chaining. :rtype: Course """ # PRINT BANNER print(utils.banner('Getting Student List From Canvas')) # List all of the students in the course resp = requests.get( url=f"{self.canvas_url}/api/v1/courses/{self.course_id}/users", headers={ "Authorization": f"Bearer {self.canvas_token}", "Accept": "application/json+canvas-string-ids" }, json={"enrollment_type": ["student"]}, ) # Make sure our request didn't fail silently resp.raise_for_status() # pull out the response JSON students = resp.json() self.students = students return self
def grade(self) -> 'Assignment': """Auto-grade an assignment within a docker container. **Commits Instructors** **Pushes Instructors** :return: The assignment object to allow for method chaining. :rtype: Assignment """ print(utils.banner(f"Autograding {self.name}")) try: res = subprocess.run([ "docker", "run", "--rm", "-u", "jupyter", "-v", f"/home/jupyter/{self.course.ins_repo_name}:/assignments/", self.course.grading_image, "autograde", self.name ], check=True) except subprocess.CalledProcessError: print( f"{utils.color.RED}Error autograding {self.name}{utils.color.END}" ) else: if res.returncode != 0: print( f"{utils.color.RED}Unspecified error autograding {self.name}{utils.color.END}" ) print(res.stdout) else: print( f"{utils.color.GREEN}Successfully autograded {self.name}{utils.color.END}" ) #===========================================# # Commit & Push Changes to Instructors Repo # #===========================================# # Do not exit if either of these operations fails. We wish to be able to # finish autograding even if we can't commit/push. try: utils.commit_repo(self.course.working_directory, f'Autograded {self.name}') except Exception as e: print('\n') print('Error committing to your instructors repository:') print(e) else: print('\n') try: utils.push_repo(self.course.working_directory) except Exception as e: print('Error pushing your repository:') print(e) return self
def feedback(self) -> 'Assignment': """Generate feedback reports for student assignments. **Commits Instructors** **Pushes Instructors** :return: The assignment object to allow for method chaining. :rtype: Assignment """ print(utils.banner(f"Generating Feedback for {self.name}")) res = self.course.nb_api.feedback(assignment_id=self.name) if res.get('error') is not None: print( f"{utils.color.RED}Error generating feedback for {self.name}{utils.color.END}" ) print(res.get('error')) else: print( f"{utils.color.GREEN}Successfully generated feedback for {self.name}{utils.color.END}" ) #===========================================# # Commit & Push Changes to Instructors Repo # #===========================================# # Do not exit if either of these operations fails. We wish to be able to # finish autograding even if we can't commit/push. try: utils.commit_repo(self.course.working_directory, f'Generated feedback for {self.name}') except Exception as e: print('\n') print('Error committing to your instructors repository:') print(e) else: print('\n') try: utils.push_repo(self.course.working_directory) except Exception as e: print('Error pushing your repository:') print(e) return self
def get_external_tool_id(self) -> 'Course': """Find the ID of the external tool created in Canvas that represents your JupyterHub server. :returns: The course object to allow for method chaining. :rtype: Course """ # PRINT BANNER print(utils.banner('Finding External Tool in Canvas')) resp = requests.get(url=urlparse.urljoin( self.canvas_url, f"/api/v1/{self.external_tool_level}s/{self.course_id}/external_tools" ), headers={ "Authorization": f"Bearer {self.canvas_token}", "Accept": "application/json+canvas-string-ids" }, params={"search_term": self.external_tool_name}) # first make sure we didn't silently error resp.raise_for_status() external_tools = resp.json() external_tool = external_tools[0] if not external_tools: sys.exit(f""" No external tools found with the name {self.external_tool_name}" at the {self.external_tool_level} level. Exiting... """) elif len(external_tools) > 1: print(f""" More than one external tool found, using the one named "{external_tool.get('name')}". Description: "{external_tool.get('description')}" """) self.external_tool_id = external_tool.get('id') return self
def schedule_grading(self) -> 'Course': """Schedule auto-grading cron jobs for all assignments. :returns: The course object to allow for method chaining. :rtype: Course """ # PRINT BANNER print(utils.banner('Scheduling Autograding')) # It would probably make more sense to use `at` instead of `cron` except that: # 1. CentOS has `cron` by default, but not `at` # 2. The python CronTab module exists to make this process quite easy. scheduling_status = [['Assignment', 'Due Date', 'Action']] # If there is no 'lock at' time, then the due date is the time to grade. # Otherwise, grade at the 'lock at' time. This is to allow partial credit # for late assignments. # Reference: https://community.canvaslms.com/docs/DOC-10327-415273044 for assignment in tqdm(self.assignments): status = assignment.schedule_grading() scheduling_status.append([ assignment.name, status.get('close_time'), status.get('action') ]) ## Print status for reporting: status_table = AsciiTable(scheduling_status) status_table.title = 'Grading Scheduling' print("\n") print(status_table.table) print("\n") return self
def submit(self) -> 'Assignment': """Upload students' grades to Canvas. :return: The assignment object to allow for method chaining. :rtype: Assignment """ # Print banner print(utils.banner(f"Submitting {self.name}")) # Check that we have the canvas_assignment containing the assignment_id # ...and if we don't, get it! if not hasattr(self, 'canvas_assignment'): self.canvas_assignment = self._search_canvas_assignment() elif self.canvas_assignment is None: self.canvas_assignment = self._search_canvas_assignment() # Pull out the assignment ID assignment_id = self.canvas_assignment.get('id') # get the student IDs try: student_ids = map(lambda stu: stu.get('id'), self.course.students) except Exception: sys.exit( "No students found. Please run `course.get_students_from_canvas()` before collecting an assignment." ) grades = self._get_grades(student_ids) # Set up status reporting submission_header = [['Student ID', 'Collection Status']] submission_status = [] # for each student for grade in grades: # upload their grade resp = requests.put(url=urlparse.urljoin( self.course.canvas_url, f"/api/v1/courses/{self.course.course_id}/assignments/{assignment_id}/submissions/{grade.get('student_id')}" ), headers={ "Authorization": f"Bearer {self.course.canvas_token}", "Accept": "application/json+canvas-string-ids" }, json={ "submission": { "posted_grade": grade.get('score') } }) if resp.status_code == 200: submission_status.append([ grade.get('student_id'), f'{utils.color.GREEN}success{utils.color.END}' ]) else: submission_status.append([ grade.get('student_id'), f'{utils.color.RED}failure{utils.color.END}' ]) table = AsciiTable(submission_header + submission_status) table.title = 'Assignment Submission' print(table.table) return self
def collect(self) -> 'Assignment': """Collect an assignment. **Commits Instructors** **Pushes Instructors** Copy your students' notebooks from the fileserver into the instructors repo `submitted/` directory. This also creates a submission in the gradebook on behalf of each student. If this is not done, then autograding doesn't record grades in the gradebook. :return: The assignment object to allow for method chaining. :rtype: Assignment """ print(utils.banner(f"Collecting {self.name}")) try: student_ids = map(lambda stu: stu.get('id'), self.course.students) except Exception: sys.exit( "No students found. Please run `course.get_students_from_canvas()` before collecting an assignment." ) # If we're using a ZFS fileserver, we need to look for the relevant snapshots if self.course.zfs: # List all of the snapshots available and parse their dates snapshot_names = os.listdir( os.path.join(self.course.storage_path, '.zfs' 'snapshot')) snapshot_name = self._find_closest_snapshot( snapshot_names, snapshot_regex=self.course.zfs_regex, datetime_pattern=self.course.zfs_datetime_pattern) zfs_path = os.path.join('.zfs', 'snapshot', snapshot_name) assignment_collection_header = [['Student ID', 'Collection Status']] assignment_collection_status = [] # get the assignment path for each student ID in Canvas for student_id in student_ids: student_path = os.path.join(self.course.storage_path, student_id, self.course.stu_repo_name, self.course.assignment_release_path) # If zfs, use the student + zfs + assignment name path # This works because our ZFS snapshots are recursive. if self.course.zfs and os.path.exists( os.path.join(student_path, '.zfs')): # Check that we're using ZFS assignment_path = os.path.join(student_path, zfs_path, self.name) # otherwise just use the student's work directly else: assignment_path = os.path.join(student_path, self.name) submission_path = os.path.join(self.course.working_directory, 'submitted', student_id, self.name) # then copy the work into the submitted directory + student_id + assignment_name # Since we're JUST copying the current assignment, we can safely overwrite it try: shutil.rmtree(submission_path) # Doesn't matter if it doesn't exist though except: pass try: shutil.copytree(assignment_path, submission_path) # if no assignment for that student, fail #* NOTE: could also be due to incorrect directory structure. except FileNotFoundError: assignment_collected = False assignment_collection_status.append( [student_id, f'{utils.color.RED}failure{utils.color.END}']) else: assignment_collected = True assignment_collection_status.append([ student_id, f'{utils.color.GREEN}success{utils.color.END}' ]) # **CRUCIALLY IMPORTANT** # If the assignment was successfully collected, create a submission in gradebook for the student. # If this is never done, then autograding doesn't # record grades in the gradebook. if assignment_collected: try: self.course.nb_api.gradebook.add_submission( self.name, student_id) self.course.nb_api.gradebook.close() except Exception as e: self.course.nb_api.gradebook.close() raise e table = AsciiTable(assignment_collection_header + assignment_collection_status) table.title = 'Assignment Collection' print(table.table) # Do not exit if either of these operations fails. We wish to be able to # finish autograding even if we can't commit/push. try: utils.commit_repo(self.course.working_directory, f'Collected {self.name}') except Exception as e: print('\n') print('Error committing to your instructors repository:') print(e) else: print('\n') try: utils.push_repo(self.course.working_directory) except Exception as e: print('Error pushing your repository:') print(e) return self
def assign( self, assignments=None, overwrite=False, ) -> 'Course': """Assign assignments for a course. :param assignments: The name or names of the assignments you wish to assign. Defaults to all assignments. :type assignments: str, List[str] :param overwrite: Bypass overwrite prompts and nuke preexisting directories. :type overwrite: bool :returns: The course object to allow for method chaining. :rtype: Course """ # PRINT BANNER print(utils.banner('Creating Student Assignments')) # Make sure we've got assignments to assign if not assignments: print( "No assignment in particular specified, assigning all assignments..." ) assignments_to_assign = self.assignments # otherwise, match the assignment(s) provided to the one in our config file else: # if just one assignment was specified... if isinstance(assignments, str): # find the assignments which match that name assignments_to_assign = list( filter(lambda assn: assn.name == assignments, self.assignments)) elif isinstance(assignments, list): assignments_to_assign = [] for assignment in assignments: match = list( filter(lambda assn: assn.name == assignments, self.assignments)) assignments_to_assign.append(match) else: sys.exit( 'Invalid argument supplied to `assign()`. Please specify a string or list of strings to assign.' ) # First things first, make the temporary student directory. No need to clone # the instructors' dir since we're working in that already, and when we # initialize the course we do a git pull to make sure we have the latest # copy. stu_repo_dir = os.path.join(self.tmp_dir, 'students') #=======================================# # Clone Repo # #=======================================# try: utils.clone_repo(self.stu_repo_url, stu_repo_dir, overwrite) except Exception as e: print("There was an error cloning your student repository") raise e #=======================================# # Make Student Version # #=======================================# # set up array to save assigned assignment names in. This will be so we can # record which assignments were assigned in the commit message. assignment_names = [] ### FOR LOOP - ASSIGN ASSIGNMENTS ### # For each assignment, assign!! print('\n') print('Creating student versions of assignments with nbgrader...') for assignment in tqdm(assignments_to_assign): # Push the name to our names array for adding to the commit message. assignment_names.append(assignment.name) # assign the given assignment! try: resp = self.nb_api.assign(assignment.name, force=True, create=True) except Exception as e: raise e else: if resp.get('error'): print(f"assigning {assignment.name} failed") print(resp.get('error')) if not resp.get('success'): print(resp.get('log')) ### END LOOP ### print('\n') print('Copying student versions to your student repository...') # set up directories to copy the assigned assignments to generated_assignment_dir = os.path.join(self.working_directory, 'release') student_assignment_dir = os.path.join(stu_repo_dir, self.assignment_release_path) #========================================# # Move Assignments to Students Repo # #========================================# if os.path.exists(student_assignment_dir): utils.safely_delete(student_assignment_dir, overwrite) # Finally, copy to the directory, as we've removed any preexisting ones or # exited if we didn't want to shutil.copytree(generated_assignment_dir, student_assignment_dir) print( 'Committing changes in your instructor and student repositories...' ) #===========================================# # Commit & Push Changes to Instructors Repo # #===========================================# utils.commit_repo(self.working_directory, f"Assigning {' '.join(assignment_names)}") print('\n') utils.push_repo(self.working_directory) #========================================# # Commit & Push Changes to Students Repo # #========================================# utils.commit_repo(stu_repo_dir, f"Assigning {' '.join(assignment_names)}") print('\n') utils.push_repo(stu_repo_dir) return self
def sync_nbgrader(self) -> 'Course': """Sync student and assignment lists between nbgrader and Canvas. :returns: The course object to allow for method chaining. :rtype: Course """ # PRINT BANNER print(utils.banner('Syncing With NBgrader')) # nbgrader API docs: # https://nbgrader.readthedocs.io/en/stable/api/gradebook.html#nbgrader.api.Gradebook gradebook = self.nb_api.gradebook try: ############################### # Update Student List # ############################### nb_student_ids = list( map(lambda _student: _student.id, gradebook.students)) # Don't use .get('id') here, because we want this to fail loudly canvas_student_ids = list( map(lambda _student: _student['id'], self.students)) # First find the students that are in canvas but NOT in nbgrader students_missing_from_nbgrader = list( set(canvas_student_ids) - set(nb_student_ids)) # Then find the students that are in nbgrader but NOT in Canvas (this # could only happen if they had withdrawn from the course) students_withdrawn_from_course = list( set(nb_student_ids) - set(canvas_student_ids)) students_no_change = set(canvas_student_ids).intersection( set(nb_student_ids)) student_header = [["Student ID", "Status", "Action"]] student_status = [] if students_missing_from_nbgrader: for student_id in students_missing_from_nbgrader: student_status.append([ student_id, "missing from nbgrader", f"{utils.color.DARKCYAN}added to nbgrader{utils.color.END}" ]) gradebook.add_student(student_id) if students_withdrawn_from_course: for student_id in students_withdrawn_from_course: gradebook.remove_student(student_id) student_status.append([ student_id, "missing from canvas", f"{utils.color.YELLOW}removed from nbgrader{utils.color.END}" ]) if students_no_change: for student_id in students_no_change: student_status.append([ student_id, u'\u2713', # unicode checkmark "none" ]) # sort the status list student_status = sorted(student_status, key=lambda k: k[0]) table = AsciiTable(student_header + student_status) table.title = 'Students' print(table.table) ################################## # Update Assignment List # ################################## nb_assignments = list( map(lambda _assignment: _assignment.name, gradebook.assignments)) config_assignments = list( map(lambda _assignment: _assignment.name, self.assignments)) assignments_missing_from_nbgrader = list( set(config_assignments) - set(nb_assignments)) assignments_withdrawn_from_config = list( set(nb_assignments) - set(config_assignments)) assignments_no_change = set(config_assignments).intersection( set(nb_assignments)) assignment_header = [["Assignment", "Status", "Action"]] assignment_status = [] if assignments_missing_from_nbgrader: for assignment_name in assignments_missing_from_nbgrader: gradebook.add_assignment(assignment_name) assignment_status.append([ assignment_name, "missing from nbgrader", f"{utils.color.DARKCYAN}added to nbgrader{utils.color.END}" ]) if assignments_withdrawn_from_config: for assignment_name in assignments_withdrawn_from_config: gradebook.remove_assignment(assignment_name) assignment_status.append([ assignment_name, "missing from config", f"{utils.color.YELLOW}removed from nbgrader{utils.color.END}" ]) if assignments_no_change: for assignment_name in assignments_no_change: assignment_status.append([ assignment_name, u'\u2713', # unicode checkmark "none" ]) # Finally, sort the status list assignment_status = sorted(assignment_status, key=lambda k: k[0]) table = AsciiTable(assignment_header + assignment_status) table.title = 'Assignments' print(table.table) # Always make sure we close the gradebook connection, even if we error except Exception as e: print(" An error occurred, closing connection to gradebook...") gradebook.close() raise e print(" Closing connection to gradebook...") gradebook.close() return self