Skip to content

Commit 7ef8445

Browse files
committed
feat: improve Canvas grade sync and add test coverage
- Added validation to skip grade sync for assignments past their due date. - Added missing test suite for Canvas grade sync tasks under `tests/`. - Minor code cleanup in `api.py` and `client.py`.
1 parent 50f59f0 commit 7ef8445

6 files changed

Lines changed: 289 additions & 1 deletion

File tree

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def get_canvas_assignments(self):
143143
assignment.get("integration_id"): {
144144
"id": assignment["id"],
145145
"is_published": assignment.get("published", False),
146+
"due_at": assignment.get("due_at"),
146147
}
147148
for assignment in assignments
148149
if assignment.get("integration_id") is not None

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import hashlib
44
import logging
5+
from datetime import UTC, datetime
56
from functools import partial
67

78
import requests
89
from celery import shared_task
910
from django.contrib.auth import get_user_model
11+
from django.utils.dateparse import parse_datetime
1012
from lms.djangoapps.courseware.courses import get_course_by_id
1113
from lms.djangoapps.grades.models import PersistentSubsectionGrade
1214
from lms.djangoapps.instructor_task.api_helper import submit_task
@@ -84,6 +86,13 @@ def push_edx_grades_to_canvas_task(entry_id, xmodule_instance_args):
8486

8587
@shared_task
8688
def sync_user_grade_with_canvas(grade_id):
89+
"""
90+
Call the Canvas API and update the user's grade.
91+
"""
92+
_sync_user_grade_with_canvas(grade_id)
93+
94+
95+
def _sync_user_grade_with_canvas(grade_id):
8796
"""
8897
Call the Canvas API and update the user's grade.
8998
"""
@@ -109,6 +118,15 @@ def sync_user_grade_with_canvas(grade_id):
109118
grade_instance.usage_key,
110119
)
111120
return
121+
due_date = existing_assignments_map[str(grade_instance.usage_key)]["due_at"]
122+
if due_date:
123+
due_date = parse_datetime(due_date)
124+
if due_date and due_date < datetime.now(tz=UTC):
125+
TASK_LOG.warning(
126+
"The assignment %s is past its due date. Skipping grade sync.",
127+
grade_instance.usage_key,
128+
)
129+
return
112130

113131
canvas_assignment_id = existing_assignments_map[str(grade_instance.usage_key)]["id"]
114132
openedx_user = USER_MODEL.objects.get(id=grade_instance.user_id)

src/ol_openedx_canvas_integration/pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ol-openedx-canvas-integration"
3-
version = "0.6.0"
3+
version = "0.7.1"
44
description = "An Open edX plugin to add canvas integration support"
55
authors = [
66
{name = "MIT Office of Digital Learning"}
@@ -48,3 +48,12 @@ include = [
4848
exclude = [
4949
"ol_openedx_canvas_integration/**/test_*"
5050
]
51+
52+
[tool.pytest.ini_options]
53+
addopts = ["--no-migrations", "--reuse-db"]
54+
filterwarnings = [
55+
"default",
56+
"ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
57+
"ignore:.*pkg_resources.*:DeprecationWarning",
58+
"ignore:.*Python 3.13.*:DeprecationWarning",
59+
]

src/ol_openedx_canvas_integration/tests/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""
2+
Pytest configuration for ol_openedx_canvas_integration tests.
3+
"""
4+
5+
from django.conf import settings
6+
7+
8+
def pytest_configure():
9+
"""Pytest hook that runs after command line options have been parsed"""
10+
11+
# Add additional Django settings needed for the plugin tests
12+
if not hasattr(settings, "BULK_EMAIL_DEFAULT_RETRY_DELAY"):
13+
settings.BULK_EMAIL_DEFAULT_RETRY_DELAY = 10
14+
if not hasattr(settings, "BULK_EMAIL_MAX_RETRIES"):
15+
settings.BULK_EMAIL_MAX_RETRIES = 5
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Tests for Canvas integration tasks"""
2+
3+
from datetime import UTC, datetime, timedelta
4+
from unittest.mock import MagicMock, patch
5+
6+
import requests
7+
from django.contrib.auth import get_user_model
8+
from django.test import TestCase, override_settings
9+
from lms.djangoapps.grades.models import PersistentSubsectionGrade
10+
from ol_openedx_canvas_integration.tasks import _sync_user_grade_with_canvas
11+
from opaque_keys.edx.keys import CourseKey, UsageKey
12+
from openedx.core.djangolib.testing.utils import skip_unless_lms
13+
14+
USER_MODEL = get_user_model()
15+
16+
17+
@override_settings(BULK_EMAIL_DEFAULT_RETRY_DELAY=10, BULK_EMAIL_MAX_RETRIES=5)
18+
@patch("ol_openedx_canvas_integration.tasks.submit_task", MagicMock(return_value=None))
19+
@patch(
20+
"ol_openedx_canvas_integration.tasks.get_course_by_id",
21+
MagicMock(return_value=MagicMock()),
22+
)
23+
@skip_unless_lms
24+
class TestSyncUserGradeWithCanvas(TestCase):
25+
"""Tests for _sync_user_grade_with_canvas task"""
26+
27+
def setUp(self):
28+
"""Setup test data"""
29+
self.grade_id = 123
30+
self.course_id = CourseKey.from_string("course-v1:org+course+run")
31+
self.usage_key = UsageKey.from_string(
32+
"block-v1:org+course+run+type@sequential+block@subsection"
33+
)
34+
self.canvas_course_id = "canvas-123"
35+
self.email = "student@example.com"
36+
self.canvas_user_id = 456
37+
self.canvas_assignment_id = 789
38+
self.user = USER_MODEL.objects.create_user(
39+
username="student",
40+
email="student@example.com",
41+
password="password", # pragma: allowlist secret # noqa: S106
42+
)
43+
self.grade_instance = PersistentSubsectionGrade.update_or_create_grade(
44+
user_id=self.user.id,
45+
id=self.grade_id,
46+
course_id=self.course_id,
47+
usage_key=self.usage_key,
48+
earned_all=6.0,
49+
possible_all=12.0,
50+
earned_graded=6.0,
51+
possible_graded=8.0,
52+
visible_blocks=[],
53+
first_attempted=datetime.now(tz=UTC),
54+
)
55+
# Mock Course
56+
self.course = MagicMock()
57+
58+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
59+
@patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades")
60+
@patch("ol_openedx_canvas_integration.tasks.TASK_LOG")
61+
@patch(
62+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
63+
MagicMock(return_value="canvas-123"),
64+
)
65+
def test_sync_success(
66+
self,
67+
mock_task_log,
68+
mock_get_grades,
69+
mock_client_class,
70+
):
71+
"""Test successful grade sync to Canvas"""
72+
mock_client = mock_client_class.return_value
73+
# The graded assignment is linked to canvas
74+
mock_client.get_canvas_assignments.return_value = {
75+
str(self.usage_key): {
76+
"id": self.canvas_assignment_id,
77+
"due_at": "",
78+
}
79+
}
80+
# The user is linked to a Canvas user
81+
mock_client.get_student_id_by_email.return_value = self.canvas_user_id
82+
mock_client.update_assignment_grades.return_value = MagicMock(
83+
status_code=requests.codes.ok
84+
)
85+
86+
grade_obj = MagicMock()
87+
grade_obj.percent_graded = 0.85
88+
mock_get_grades.return_value = {self.usage_key: {self.user: grade_obj}}
89+
90+
_sync_user_grade_with_canvas(self.grade_id)
91+
92+
mock_client.update_assignment_grades.assert_called_once()
93+
94+
mock_task_log.error.assert_not_called()
95+
96+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
97+
@patch(
98+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
99+
MagicMock(return_value=None),
100+
)
101+
def test_no_canvas_id(
102+
self,
103+
mock_client_class,
104+
):
105+
"""Test that grades are not synced if course has no linked canvas id"""
106+
_sync_user_grade_with_canvas(self.grade_id)
107+
108+
mock_client_class.assert_not_called()
109+
110+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
111+
@patch(
112+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
113+
MagicMock(return_value="canvas-123"),
114+
)
115+
def test_assignment_not_synced_to_canvas(
116+
self,
117+
mock_client_class,
118+
):
119+
"""Test that assignments that are not synced to Canvas are not updated."""
120+
mock_client = mock_client_class.return_value
121+
# There are assignment synced to Canvas, but not the one being graded
122+
mock_client.get_canvas_assignments.return_value = {
123+
"dummy-key": {
124+
"id": self.canvas_assignment_id,
125+
"due_at": "",
126+
}
127+
}
128+
129+
_sync_user_grade_with_canvas(self.grade_id)
130+
131+
mock_client.update_assignment_grades.assert_not_called()
132+
133+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
134+
@patch(
135+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
136+
MagicMock(return_value="canvas-123"),
137+
)
138+
def test_assignment_past_due_date(
139+
self,
140+
mock_client_class,
141+
):
142+
"""Test that grades are not synced for assignments past due date"""
143+
mock_client = mock_client_class.return_value
144+
# Assignment is synced to Canvas, but past due date
145+
mock_client.get_canvas_assignments.return_value = {
146+
str(self.usage_key): {
147+
"id": self.canvas_assignment_id,
148+
"due_at": str(datetime.now(tz=UTC) - timedelta(days=1)),
149+
}
150+
}
151+
152+
_sync_user_grade_with_canvas(self.grade_id)
153+
154+
mock_client.update_assignment_grades.assert_not_called()
155+
156+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
157+
@patch(
158+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
159+
MagicMock(return_value="canvas-123"),
160+
)
161+
def test_no_canvas_user_id(
162+
self,
163+
mock_client_class,
164+
):
165+
"""Test that grades are not synced if user is not linked to a Canvas user"""
166+
mock_client = mock_client_class.return_value
167+
# The assignment is linked to Canvas ...
168+
mock_client.get_canvas_assignments.return_value = {
169+
str(self.usage_key): {
170+
"id": self.canvas_assignment_id,
171+
"due_at": "",
172+
}
173+
}
174+
# ... but the user is not linked to a Canvas user
175+
mock_client.get_student_id_by_email.return_value = None
176+
177+
_sync_user_grade_with_canvas(self.grade_id)
178+
179+
mock_client.update_assignment_grades.assert_not_called()
180+
181+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
182+
@patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades")
183+
@patch(
184+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
185+
MagicMock(return_value="canvas-123"),
186+
)
187+
def test_sync_key_error(
188+
self,
189+
mock_get_grades,
190+
mock_client_class,
191+
):
192+
"""Test that grades are not synced if the usage key is not found in the grades dict"""
193+
mock_client = mock_client_class.return_value
194+
mock_client.get_canvas_assignments.return_value = {
195+
str(self.usage_key): {
196+
"id": self.canvas_assignment_id,
197+
"due_at": "",
198+
}
199+
}
200+
mock_client.get_student_id_by_email.return_value = self.canvas_user_id
201+
mock_client.update_assignment_grades.return_value = MagicMock(
202+
status_code=requests.codes.ok
203+
)
204+
205+
grade_obj = MagicMock()
206+
grade_obj.percent_graded = 0.85
207+
mock_get_grades.return_value = {"dummy-key": {self.user: grade_obj}}
208+
209+
_sync_user_grade_with_canvas(self.grade_id)
210+
211+
mock_client.update_assignment_grades.assert_not_called()
212+
213+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
214+
@patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades")
215+
@patch("ol_openedx_canvas_integration.tasks.TASK_LOG")
216+
@patch(
217+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
218+
MagicMock(return_value="canvas-123"),
219+
)
220+
def test_sync_fail_code(
221+
self,
222+
mock_task_log,
223+
mock_get_grades,
224+
mock_client_class,
225+
):
226+
"""Test that grades are not synced if the Canvas API returns an error"""
227+
mock_client = mock_client_class.return_value
228+
mock_client.get_canvas_assignments.return_value = {
229+
str(self.usage_key): {
230+
"id": self.canvas_assignment_id,
231+
"due_at": "",
232+
}
233+
}
234+
mock_client.get_student_id_by_email.return_value = self.canvas_user_id
235+
mock_client.update_assignment_grades.return_value = MagicMock(status_code=502)
236+
237+
grade_obj = MagicMock()
238+
grade_obj.percent_graded = 0.85
239+
mock_get_grades.return_value = {self.usage_key: {self.user: grade_obj}}
240+
241+
_sync_user_grade_with_canvas(self.grade_id)
242+
243+
mock_client.update_assignment_grades.assert_called_once()
244+
245+
mock_task_log.error.assert_called_once()

0 commit comments

Comments
 (0)