Example #1
0
    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
Example #2
0
    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
Example #3
0
    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
Example #4
0
    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
Example #5
0
    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
Example #6
0
    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
Example #7
0
    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
Example #8
0
    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
Example #9
0
    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
Example #10
0
    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