Skip to content

Commit e7a57a2

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 01b9ebd commit e7a57a2

4 files changed

Lines changed: 270 additions & 2 deletions

File tree

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py

Lines changed: 19 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 datetime, timezone
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
"""
@@ -110,6 +119,16 @@ def sync_user_grade_with_canvas(grade_id):
110119
)
111120
return
112121

122+
due_date = parse_datetime(
123+
existing_assignments_map[str(grade_instance.usage_key)]["due_at"]
124+
)
125+
if due_date and due_date < datetime.now(tz=timezone.utc):
126+
TASK_LOG.warning(
127+
"The assignment %s is past its due date. Skipping grade sync.",
128+
grade_instance.usage_key,
129+
)
130+
return
131+
113132
canvas_assignment_id = existing_assignments_map[str(grade_instance.usage_key)]["id"]
114133
openedx_user = USER_MODEL.objects.get(id=grade_instance.user_id)
115134
canvas_user_id = client.get_student_id_by_email(openedx_user.email)

src/ol_openedx_canvas_integration/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ exclude = [
4949
"ol_openedx_canvas_integration/**/test_*"
5050
]
5151

52-
[tool.pytest]
53-
addopts = ["--nomigrations", "--reuse-db"]
52+
[tool.pytest.ini_options]
53+
addopts = ["--no-migrations", "--reuse-db"]
5454
filterwarnings = [
5555
"default",
5656
"ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
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: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""Tests for Canvas integration tasks"""
2+
3+
from datetime import 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 override_settings, TestCase
9+
from opaque_keys.edx.keys import UsageKey, CourseKey
10+
11+
from ol_openedx_canvas_integration.tasks import _sync_user_grade_with_canvas
12+
from lms.djangoapps.grades.models import PersistentSubsectionGrade
13+
from openedx.core.djangolib.testing.utils import skip_unless_lms
14+
15+
USER_MODEL = get_user_model()
16+
17+
18+
@override_settings(BULK_EMAIL_DEFAULT_RETRY_DELAY=10, BULK_EMAIL_MAX_RETRIES=5)
19+
@patch("ol_openedx_canvas_integration.tasks.submit_task", MagicMock(return_value=None))
20+
@patch(
21+
"ol_openedx_canvas_integration.tasks.get_course_by_id",
22+
MagicMock(return_value=MagicMock()),
23+
)
24+
@skip_unless_lms
25+
class TestSyncUserGradeWithCanvas(TestCase):
26+
"""Tests for _sync_user_grade_with_canvas task"""
27+
28+
def setUp(self):
29+
"""Setup test data"""
30+
self.grade_id = 123
31+
self.course_id = CourseKey.from_string("course-v1:org+course+run")
32+
self.usage_key = UsageKey.from_string(
33+
"block-v1:org+course+run+type@sequential+block@subsection"
34+
)
35+
self.canvas_course_id = "canvas-123"
36+
self.email = "student@example.com"
37+
self.canvas_user_id = 456
38+
self.canvas_assignment_id = 789
39+
self.user = USER_MODEL.objects.create_user(
40+
username="student", email="student@example.com", password="password"
41+
)
42+
self.grade_instance = PersistentSubsectionGrade.update_or_create_grade(
43+
user_id=self.user.id,
44+
id=self.grade_id,
45+
course_id=self.course_id,
46+
usage_key=self.usage_key,
47+
earned_all=6.0,
48+
possible_all=12.0,
49+
earned_graded=6.0,
50+
possible_graded=8.0,
51+
visible_blocks=[],
52+
first_attempted=datetime.now(),
53+
)
54+
# Mock Course
55+
self.course = MagicMock()
56+
57+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
58+
@patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades")
59+
@patch("ol_openedx_canvas_integration.tasks.TASK_LOG")
60+
@patch(
61+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
62+
MagicMock(return_value="canvas-123"),
63+
)
64+
def test_sync_success(
65+
self,
66+
mock_task_log,
67+
mock_get_grades,
68+
mock_client_class,
69+
):
70+
"""Test successful grade sync to Canvas"""
71+
mock_client = mock_client_class.return_value
72+
mock_client.get_canvas_assignments.return_value = {
73+
str(self.usage_key): {
74+
"id": self.canvas_assignment_id,
75+
"due_at": "",
76+
}
77+
}
78+
mock_client.get_student_id_by_email.return_value = self.canvas_user_id
79+
mock_client.update_assignment_grades.return_value = MagicMock(
80+
status_code=requests.codes.ok
81+
)
82+
83+
grade_obj = MagicMock()
84+
grade_obj.percent_graded = 0.85
85+
mock_get_grades.return_value = {self.usage_key: {self.user: grade_obj}}
86+
87+
_sync_user_grade_with_canvas(self.grade_id)
88+
89+
mock_client.update_assignment_grades.assert_called_once()
90+
91+
mock_task_log.error.assert_not_called()
92+
93+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
94+
@patch(
95+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
96+
MagicMock(return_value=None),
97+
)
98+
def test_no_canvas_id(
99+
self,
100+
mock_client_class,
101+
):
102+
103+
_sync_user_grade_with_canvas(self.grade_id)
104+
105+
mock_client_class.assert_not_called()
106+
107+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
108+
@patch(
109+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
110+
MagicMock(return_value="canvas-123"),
111+
)
112+
def test_assignment_not_synced(
113+
self,
114+
mock_client_class,
115+
):
116+
mock_client = mock_client_class.return_value
117+
mock_client.get_canvas_assignments.return_value = {
118+
"dummy-key": {
119+
"id": self.canvas_assignment_id,
120+
"due_at": "",
121+
}
122+
}
123+
124+
_sync_user_grade_with_canvas(self.grade_id)
125+
126+
mock_client.update_assignment_grades.assert_not_called()
127+
128+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
129+
@patch(
130+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
131+
MagicMock(return_value="canvas-123"),
132+
)
133+
def test_assignment_past_due_date(
134+
self,
135+
mock_client_class,
136+
):
137+
mock_client = mock_client_class.return_value
138+
mock_client.get_canvas_assignments.return_value = {
139+
"dummy-key": {
140+
"id": self.canvas_assignment_id,
141+
"due_at": str(datetime.now() - timedelta(days=1)),
142+
}
143+
}
144+
145+
_sync_user_grade_with_canvas(self.grade_id)
146+
147+
mock_client.update_assignment_grades.assert_not_called()
148+
149+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
150+
@patch(
151+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
152+
MagicMock(return_value="canvas-123"),
153+
)
154+
def test_no_canvas_user_id(
155+
self,
156+
mock_client_class,
157+
):
158+
mock_client = mock_client_class.return_value
159+
mock_client.get_canvas_assignments.return_value = {
160+
str(self.usage_key): {
161+
"id": self.canvas_assignment_id,
162+
"due_at": "",
163+
}
164+
}
165+
mock_client.get_student_id_by_email.return_value = None
166+
167+
_sync_user_grade_with_canvas(self.grade_id)
168+
169+
mock_client.update_assignment_grades.assert_not_called()
170+
171+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
172+
@patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades")
173+
@patch(
174+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
175+
MagicMock(return_value="canvas-123"),
176+
)
177+
def test_sync_key_error(
178+
self,
179+
mock_get_grades,
180+
mock_client_class,
181+
):
182+
"""Test successful grade sync to Canvas"""
183+
mock_client = mock_client_class.return_value
184+
mock_client.get_canvas_assignments.return_value = {
185+
str(self.usage_key): {
186+
"id": self.canvas_assignment_id,
187+
"due_at": "",
188+
}
189+
}
190+
mock_client.get_student_id_by_email.return_value = self.canvas_user_id
191+
mock_client.update_assignment_grades.return_value = MagicMock(
192+
status_code=requests.codes.ok
193+
)
194+
195+
grade_obj = MagicMock()
196+
grade_obj.percent_graded = 0.85
197+
mock_get_grades.return_value = {"dummy-key": {self.user: grade_obj}}
198+
199+
_sync_user_grade_with_canvas(self.grade_id)
200+
201+
mock_client.update_assignment_grades.assert_not_called()
202+
203+
@patch("ol_openedx_canvas_integration.tasks.CanvasClient")
204+
@patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades")
205+
@patch("ol_openedx_canvas_integration.tasks.TASK_LOG")
206+
@patch(
207+
"ol_openedx_canvas_integration.tasks.get_canvas_course_id",
208+
MagicMock(return_value="canvas-123"),
209+
)
210+
def test_sync_fail_code(
211+
self,
212+
mock_task_log,
213+
mock_get_grades,
214+
mock_client_class,
215+
):
216+
mock_client = mock_client_class.return_value
217+
mock_client.get_canvas_assignments.return_value = {
218+
str(self.usage_key): {
219+
"id": self.canvas_assignment_id,
220+
"due_at": "",
221+
}
222+
}
223+
mock_client.get_student_id_by_email.return_value = self.canvas_user_id
224+
mock_client.update_assignment_grades.return_value = MagicMock(status_code=502)
225+
226+
grade_obj = MagicMock()
227+
grade_obj.percent_graded = 0.85
228+
mock_get_grades.return_value = {self.usage_key: {self.user: grade_obj}}
229+
230+
_sync_user_grade_with_canvas(self.grade_id)
231+
232+
mock_client.update_assignment_grades.assert_called_once()
233+
234+
mock_task_log.error.assert_called_once()

0 commit comments

Comments
 (0)