Skip to content

Commit 4127e8d

Browse files
asadiqbal08George Schneelochgsidebo
authored andcommitted
Canvas Commits (#223)
* Sync canvas enrollments * Added instructor dashboard button to push edX grades to Canvas * Fixed canvas grade syncing JS * Changed 'edX' reference to 'MITx' * Use EDIT_COURSE_ACCESS permission instead of is_staff * Use OVERRIDE_GRADES rule instead * Use instructor_tasks for canvas work (#183) * Add per_page to paginated requests * Static method * Fix email lowercase mismatch * Use instructor_tasks to handle canvas work * Fix transaction error * Decorator needs to be at the top * Attempt to fix polling of tasks * More task bug fixes * Fix typo * Fix course key bug * Update message for push edx grades command * Use course_key so sync_canvas_enrollments uses the same message * Format the submitted time * Don't return output to prevent task output max size error (#189) Co-authored-by: George Schneeloch <[email protected]> Co-authored-by: Gavin Sidebottom <[email protected]>
1 parent 727c3c8 commit 4127e8d

File tree

27 files changed

+1949
-2
lines changed

27 files changed

+1949
-2
lines changed

common/lib/xmodule/xmodule/course_module.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,13 @@ class CourseFields(object):
490490
),
491491
scope=Scope.settings
492492
)
493+
canvas_course_id = Integer(
494+
display_name=_("Canvas Course Id"),
495+
help=_(
496+
"The id for the corresponding course on Canvas"
497+
),
498+
scope=Scope.settings
499+
)
493500
enable_ccx = Boolean(
494501
# Translators: Custom Courses for edX (CCX) is an edX feature for re-using course content. CCX Coach is
495502
# a role created by a course Instructor to enable a person (the "Coach") to manage the custom course for

lms/djangoapps/canvas_integration/__init__.py

Whitespace-only changes.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
Canvas Integration Application Configuration
3+
"""
4+
5+
from django.apps import AppConfig
6+
from openedx.core.constants import COURSE_ID_PATTERN
7+
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType, PluginURLs, PluginSettings
8+
9+
10+
class CanvasIntegrationConfig(AppConfig):
11+
"""
12+
Configuration class for Canvas integration app
13+
"""
14+
name = u'canvas_integration'
15+
16+
plugin_app = {
17+
PluginURLs.CONFIG: {
18+
ProjectType.LMS: {
19+
PluginURLs.NAMESPACE: u'',
20+
PluginURLs.REGEX: u'courses/{}/canvas/api/'.format(COURSE_ID_PATTERN),
21+
PluginURLs.RELATIVE_PATH: u'urls',
22+
}
23+
},
24+
PluginSettings.CONFIG: {
25+
ProjectType.LMS: {
26+
SettingsType.PRODUCTION: {PluginSettings.RELATIVE_PATH: u'settings.production'},
27+
SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: u'settings.common'},
28+
}
29+
}
30+
}

0 commit comments

Comments
 (0)