|
| 1 | +"""Utility functions for canvas integration""" |
| 2 | +import logging |
| 3 | +from collections import defaultdict |
| 4 | + |
| 5 | +from opaque_keys.edx.locator import CourseLocator |
| 6 | + |
| 7 | +from courseware.courses import get_course_by_id |
| 8 | +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory |
| 9 | +from remote_gradebook.api import enroll_emails_in_course, get_enrolled_non_staff_users, course_graded_items |
| 10 | +from student.models import CourseEnrollment |
| 11 | +from canvas_integration.client import CanvasClient, create_assignment_payload, update_grade_payload_kv |
| 12 | + |
| 13 | +log = logging.getLogger(__name__) |
| 14 | + |
| 15 | + |
| 16 | +def first_or_none(iterable): |
| 17 | + """Returns the first item in the given iterable, or None if the iterable is empty""" |
| 18 | + return next((x for x in iterable), None) |
| 19 | + |
| 20 | + |
| 21 | +def get_subsection_user_grades(course): |
| 22 | + """ |
| 23 | + Builds a dict of user grades grouped by block locator. Only returns grades if the assignment has been attempted |
| 24 | + by the given user. |
| 25 | +
|
| 26 | + Args: |
| 27 | + course: The course object (of the type returned by courseware.courses.get_course_by_id) |
| 28 | +
|
| 29 | + Returns: |
| 30 | + dict: Block locators for graded items (assignments, exams, etc.) mapped to a dict of users |
| 31 | + and their grades for those assignments. |
| 32 | + Example: { |
| 33 | + <BlockUsageLocator for graded item>: { |
| 34 | + <User object for student 1>: <grades.subsection_grade.CreateSubsectionGrade object>, |
| 35 | + <User object for student 2>: <grades.subsection_grade.CreateSubsectionGrade object>, |
| 36 | + } |
| 37 | + } |
| 38 | + """ |
| 39 | + enrolled_students = CourseEnrollment.objects.users_enrolled_in(course.id) |
| 40 | + subsection_grade_dict = defaultdict(dict) |
| 41 | + for student, course_grade, error in CourseGradeFactory().iter(users=enrolled_students, course=course): |
| 42 | + for graded_item_type, subsection_dict in course_grade.graded_subsections_by_format.items(): |
| 43 | + for subsection_block_locator, subsection_grade in subsection_dict.items(): |
| 44 | + subsection_grade_dict[subsection_block_locator].update( |
| 45 | + # Only include grades if the assignment/exam/etc. has been attempted |
| 46 | + {student: subsection_grade} |
| 47 | + if subsection_grade.graded_total.first_attempted |
| 48 | + else {} |
| 49 | + ) |
| 50 | + return subsection_grade_dict |
| 51 | + |
| 52 | + |
| 53 | +def get_subsection_block_user_grades(course): |
| 54 | + """ |
| 55 | + Builds a dict of user grades grouped by the subsection XBlock representing each graded item. |
| 56 | + Only returns grades if the assignment has been attempted by the given user. |
| 57 | +
|
| 58 | + Args: |
| 59 | + course: The course object (of the type returned by courseware.courses.get_course_by_id) |
| 60 | +
|
| 61 | + Returns: |
| 62 | + dict: Block objects representing graded items (assignments, exams, etc.) mapped to a dict of users |
| 63 | + and their grades for those assignments. |
| 64 | + Example: { |
| 65 | + <content.block_structure.block_structure.BlockData object for graded item>: { |
| 66 | + <User object for student 1>: <grades.subsection_grade.CreateSubsectionGrade object>, |
| 67 | + <User object for student 2>: <grades.subsection_grade.CreateSubsectionGrade object>, |
| 68 | + } |
| 69 | + } |
| 70 | + """ |
| 71 | + subsection_user_grades = get_subsection_user_grades(course) |
| 72 | + graded_subsection_blocks = [ |
| 73 | + graded_item.get("subsection_block") |
| 74 | + for graded_item_type, graded_item, graded_item_index in course_graded_items(course) |
| 75 | + ] |
| 76 | + locator_block_dict = { |
| 77 | + block_locator: first_or_none((block for block in graded_subsection_blocks if block.location == block_locator)) |
| 78 | + for block_locator in subsection_user_grades.keys() |
| 79 | + } |
| 80 | + return { |
| 81 | + block: subsection_user_grades[block_locator] |
| 82 | + for block_locator, block in locator_block_dict.items() |
| 83 | + if block is not None |
| 84 | + } |
| 85 | + |
| 86 | + |
| 87 | +def sync_canvas_enrollments(course_key, canvas_course_id, unenroll_current): |
| 88 | + """ |
| 89 | + Fetch enrollments from canvas and update |
| 90 | +
|
| 91 | + Args: |
| 92 | + course_key (str): The edX course key |
| 93 | + canvas_course_id (int): The canvas course id |
| 94 | + unenroll_current (bool): If true, unenroll existing students if not staff |
| 95 | + """ |
| 96 | + client = CanvasClient(canvas_course_id) |
| 97 | + emails_to_enroll = client.list_canvas_enrollments() |
| 98 | + users_to_unenroll = [] |
| 99 | + |
| 100 | + course_key = CourseLocator.from_string(course_key) |
| 101 | + course = get_course_by_id(course_key) |
| 102 | + |
| 103 | + if unenroll_current: |
| 104 | + enrolled_user_dict = {user.email: user for user in get_enrolled_non_staff_users(course)} |
| 105 | + emails_to_enroll_set = set(emails_to_enroll) |
| 106 | + already_enrolled_email_set = set(enrolled_user_dict.keys()) |
| 107 | + emails_to_enroll = emails_to_enroll_set - already_enrolled_email_set |
| 108 | + users_to_unenroll = [enrolled_user_dict[email] for email in (already_enrolled_email_set - emails_to_enroll)] |
| 109 | + |
| 110 | + enrolled = enroll_emails_in_course(emails=emails_to_enroll, course_key=course_key) |
| 111 | + log.info("Enrolled users in course %s: %s", course_key, enrolled) |
| 112 | + |
| 113 | + if users_to_unenroll: |
| 114 | + for user_to_unenroll in users_to_unenroll: |
| 115 | + CourseEnrollment.unenroll(user_to_unenroll, course.id) |
| 116 | + log.info("Unenrolled non-staff users in course %s: %s", course_key, users_to_unenroll) |
| 117 | + |
| 118 | + |
| 119 | +def push_edx_grades_to_canvas(course): |
| 120 | + """ |
| 121 | + Gathers all student grades for each assignment in the given course, creates equivalent assignment in Canvas |
| 122 | + if they don't exist already, and adds/updates the student grades for those assignments in Canvas. |
| 123 | +
|
| 124 | + Args: |
| 125 | + course: The course object (of the type returned by courseware.courses.get_course_by_id) |
| 126 | +
|
| 127 | + Returns: |
| 128 | + dict: A dictionary with some information about the success/failure of the updates |
| 129 | + """ |
| 130 | + canvas_course_id = course.canvas_course_id |
| 131 | + client = CanvasClient(canvas_course_id=canvas_course_id) |
| 132 | + existing_assignment_dict = client.get_assignments_by_int_id() |
| 133 | + subsection_block_user_grades = get_subsection_block_user_grades(course) |
| 134 | + |
| 135 | + # Populate missing assignments |
| 136 | + new_assignment_blocks = ( |
| 137 | + subsection_block for subsection_block in subsection_block_user_grades.keys() |
| 138 | + if str(subsection_block.location) not in existing_assignment_dict |
| 139 | + ) |
| 140 | + created_assignments = { |
| 141 | + subsection_block: client.create_canvas_assignment( |
| 142 | + create_assignment_payload(subsection_block) |
| 143 | + ) |
| 144 | + for subsection_block in new_assignment_blocks |
| 145 | + } |
| 146 | + |
| 147 | + # Build request payloads for updating grades in each assignment |
| 148 | + enrolled_user_dict = client.list_canvas_enrollments() |
| 149 | + grade_update_payloads = {} |
| 150 | + for subsection_block, user_grade_dict in subsection_block_user_grades.items(): |
| 151 | + grade_update_payloads[subsection_block] = dict( |
| 152 | + update_grade_payload_kv( |
| 153 | + enrolled_user_dict[student_user.email.lower()], |
| 154 | + grade.percent_graded |
| 155 | + ) |
| 156 | + for student_user, grade in user_grade_dict.items() |
| 157 | + # Only add the grade if the user exists in Canvas |
| 158 | + if student_user.email.lower() in enrolled_user_dict |
| 159 | + ) |
| 160 | + |
| 161 | + # Send requests to update grades in each relevant course |
| 162 | + assignment_grades_updated = { |
| 163 | + subsection_block: client.update_assignment_grades( |
| 164 | + canvas_assignment_id=existing_assignment_dict[str(subsection_block.location)], |
| 165 | + payload=grade_request_payload, |
| 166 | + ) |
| 167 | + for subsection_block, grade_request_payload in grade_update_payloads.items() |
| 168 | + if grade_request_payload and str(subsection_block.location) in existing_assignment_dict |
| 169 | + } |
| 170 | + |
| 171 | + return assignment_grades_updated, created_assignments |
0 commit comments