Skip to content

Commit a87ef4e

Browse files
committed
feat: sync Canvas due date extensions for students
- Added `sync_canvas_due_date_extensions` to handle due date extensions for specific students in Canvas. - Extended `_sync_canvas_due_dates` to update due dates with overrides. - Added `get_emails_by_student_ids` method in `client.py` to fetch student emails by ID from Canvas. - Updated `list_canvas_assignments` to support fetching overrides with assignments.
1 parent e7a57a2 commit a87ef4e

5 files changed

Lines changed: 144 additions & 14 deletions

File tree

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def list_canvas_enrollments(self):
8585
for enrollment in enrollments
8686
}
8787

88-
def list_canvas_assignments(self):
88+
def list_canvas_assignments(self, *args, **kwargs):
8989
"""
9090
List Canvas assignments
9191
@@ -96,7 +96,7 @@ def list_canvas_assignments(self):
9696
settings.CANVAS_BASE_URL,
9797
f"/api/v1/courses/{self.canvas_course_id}/assignments",
9898
)
99-
return self._paginate(url)
99+
return self._paginate(url, *args, **kwargs)
100100

101101
def get_student_id_by_email(self, email: str):
102102
"""
@@ -129,6 +129,24 @@ def get_student_id_by_email(self, email: str):
129129
cache.set(key, student_id)
130130
return student_id
131131

132+
def get_emails_by_student_ids(self, student_ids: list[int]):
133+
"""
134+
Retrieve emails for a list of student IDs from Canvas.
135+
136+
Args:
137+
student_ids (list[int]): List of student IDs.
138+
139+
Returns:
140+
list[str]: List of student emails.
141+
"""
142+
url = urljoin(
143+
settings.CANVAS_BASE_URL,
144+
f"/api/v1/courses/{self.canvas_course_id}/users",
145+
)
146+
results = self._paginate(url, params={"user_ids[]": student_ids})
147+
148+
return [user["login_id"].lower() for user in results if "login_id" in user]
149+
132150
def get_canvas_assignments(self):
133151
"""
134152
Get Canvas assignments organized by integration_id.
@@ -138,12 +156,15 @@ def get_canvas_assignments(self):
138156
'id' and 'is_published' fields. Only includes assignments with
139157
integration_id set.
140158
"""
141-
assignments = self.list_canvas_assignments()
159+
# This query param makes canvas return all overrides for each assignment
160+
query = {"include[]": ["overrides"]}
161+
assignments = self.list_canvas_assignments(params=query)
142162
assignments_dict = {
143163
assignment.get("integration_id"): {
144164
"id": assignment["id"],
145165
"is_published": assignment.get("published", False),
146166
"due_at": assignment.get("due_at"),
167+
"overrides": assignment.get("overrides", []),
147168
}
148169
for assignment in assignments
149170
if assignment.get("integration_id") is not None
@@ -297,4 +318,4 @@ def update_grade_payload_kv(user_id, grade_percent):
297318
Returns:
298319
(tuple): A key/value pair that will be used in the body of a bulk grade update request
299320
""" # noqa: D401, E501
300-
return (f"grade_data[{user_id}][posted_grade]", f"{grade_percent * 100}%")
321+
return f"grade_data[{user_id}][posted_grade]", f"{grade_percent * 100}%"

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515

1616
import requests
1717
from celery import shared_task
18+
from django.contrib.auth import get_user_model
1819
from django.utils.dateparse import parse_datetime
1920
from lms.djangoapps.courseware.courses import get_course_by_id
21+
from lms.djangoapps.instructor.views.tools import set_due_date_extension
2022
from opaque_keys import InvalidKeyError
2123
from opaque_keys.edx.keys import CourseKey, UsageKey
2224
from opaque_keys.edx.locator import CourseLocator
@@ -33,6 +35,7 @@
3335

3436
logger = logging.getLogger(__name__)
3537
TASK_LOG = logging.getLogger("edx.celery.task")
38+
User = get_user_model()
3639

3740

3841
def diff_assignments(
@@ -215,6 +218,38 @@ def sync_canvas_due_dates(course_id: str):
215218
_sync_canvas_due_dates(course_id)
216219

217220

221+
def sync_canvas_due_date_extensions(client, course, block, overrides):
222+
"""
223+
Synchronize due date extensions for students in Canvas with the platform.
224+
225+
Parameters:
226+
client (CanvasAPIClient): The Canvas API client for making requests.
227+
course (Course): Course object for which due date extensions are being synced.
228+
block (Block): Block object for which due date extensions are being synced.
229+
overrides (list): List of due date overrides from Canvas.
230+
"""
231+
if not overrides:
232+
return
233+
canvas_course_id = get_canvas_course_id(course)
234+
for override in overrides:
235+
if "student_ids" in override:
236+
emails = client.get_emails_by_student_ids(override["student_ids"])
237+
students = User.objects.filter(email__in=emails)
238+
for student in students:
239+
TASK_LOG.info(
240+
"Due Date Sync: Syncing due date for student %s in course %s",
241+
student.id,
242+
course.id,
243+
)
244+
set_due_date_extension(
245+
course,
246+
block,
247+
student,
248+
override["due_at"],
249+
reason=f"Synced from canvas course: {canvas_course_id}",
250+
)
251+
252+
218253
def _sync_canvas_due_dates(course_id: str):
219254
"""
220255
Synchronize assignment due dates from Canvas to a specific course in the platform.
@@ -259,6 +294,9 @@ def _sync_canvas_due_dates(course_id: str):
259294
usage_key = UsageKey.from_string(usage_id)
260295
due_at = canvas_assignment.get("due_at")
261296
block = modulestore().get_item(usage_key)
297+
sync_canvas_due_date_extensions(
298+
client, course, block, canvas_assignment.get("overrides")
299+
)
262300
if due_at:
263301
block.due = parse_datetime(due_at)
264302
else:

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import hashlib
44
import logging
5-
from datetime import datetime, timezone
5+
from datetime import UTC, datetime
66
from functools import partial
77

88
import requests
@@ -122,7 +122,7 @@ def _sync_user_grade_with_canvas(grade_id):
122122
due_date = parse_datetime(
123123
existing_assignments_map[str(grade_instance.usage_key)]["due_at"]
124124
)
125-
if due_date and due_date < datetime.now(tz=timezone.utc):
125+
if due_date and due_date < datetime.now(tz=UTC):
126126
TASK_LOG.warning(
127127
"The assignment %s is past its due date. Skipping grade sync.",
128128
grade_instance.usage_key,

src/ol_openedx_canvas_integration/tests/test_cms_tasks.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from __future__ import annotations
22

3-
from unittest.mock import MagicMock, patch
3+
from unittest.mock import ANY, MagicMock, patch
44

55
import ddt
66
import pytest
7+
from django.contrib.auth.models import User
78
from django.test import override_settings
89
from django.utils.dateparse import parse_datetime
910
from ol_openedx_canvas_integration.api import create_assignment_payload
@@ -211,3 +212,71 @@ def test_sync_canvas_due_dates_updates_due_dates(self):
211212
due_at = data.get("due_at", None)
212213
due_at = parse_datetime(due_at) if due_at else None
213214
assert self.store.get_item(seq_key).due == due_at
215+
216+
def test_sync_canvas_due_date_extensions(self):
217+
for uid in [1, 4, 9, 11, 14, 37]:
218+
User.objects.create_user(f"user{uid}", f"user{uid}@abc.xyz", "password")
219+
course, sequentials = self.create_course(
220+
{"canvas_id": 11, "use_canvas_due_dates": True}
221+
)
222+
223+
sequential_0_student_ids = [11, 37, 4]
224+
sequential_1_student_ids = [1, 9, 14]
225+
226+
mock_canvas_assignments = {
227+
str(sequentials[0].location): {
228+
"due_at": "2026-06-01T00:00:00Z",
229+
"overrides": [
230+
{
231+
"due_at": "2026-06-02T00:00:00Z",
232+
"student_ids": sequential_0_student_ids,
233+
}
234+
],
235+
},
236+
str(sequentials[1].location): {
237+
"due_at": None,
238+
"overrides": [
239+
{
240+
"due_at": "2026-06-04T00:00:00Z",
241+
"student_ids": sequential_1_student_ids,
242+
}
243+
],
244+
},
245+
}
246+
247+
canvas_client_mock = MagicMock()
248+
canvas_client_mock.get_canvas_assignments.return_value = mock_canvas_assignments
249+
canvas_client_mock.get_emails_by_student_ids.side_effect = lambda ids: [
250+
f"user{uid}@abc.xyz" for uid in ids
251+
]
252+
253+
with (
254+
patch(
255+
"ol_openedx_canvas_integration.cms_tasks.CanvasClient",
256+
return_value=canvas_client_mock,
257+
),
258+
patch(
259+
"ol_openedx_canvas_integration.cms_tasks.set_due_date_extension"
260+
) as set_due_date_extension_mock,
261+
):
262+
_sync_canvas_due_dates(str(course.id))
263+
# 3 student extensions each for 2 assignments
264+
assert set_due_date_extension_mock.call_count == (
265+
len(sequential_0_student_ids) + len(sequential_1_student_ids)
266+
)
267+
for call_args, _ in set_due_date_extension_mock.call_args_list:
268+
assert call_args[0].id == course.id
269+
assert call_args[1].location in (
270+
sequentials[0].location,
271+
sequentials[1].location,
272+
)
273+
274+
for student_id in sequential_0_student_ids:
275+
for _ in sequentials:
276+
set_due_date_extension_mock.assert_any_call(
277+
ANY,
278+
ANY,
279+
User.objects.get(email=f"user{student_id}@abc.xyz"),
280+
"2026-06-02T00:00:00Z",
281+
reason="Synced from canvas course: 11",
282+
)

src/ol_openedx_canvas_integration/tests/test_tasks.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55

66
import requests
77
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
8+
from django.test import TestCase, override_settings
129
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
1312
from openedx.core.djangolib.testing.utils import skip_unless_lms
13+
from pytz import UTC
1414

1515
USER_MODEL = get_user_model()
1616

@@ -37,7 +37,9 @@ def setUp(self):
3737
self.canvas_user_id = 456
3838
self.canvas_assignment_id = 789
3939
self.user = USER_MODEL.objects.create_user(
40-
username="student", email="student@example.com", password="password"
40+
username="student",
41+
email="student@example.com",
42+
password="password", # noqa: S106 # pragma: allowlist secret
4143
)
4244
self.grade_instance = PersistentSubsectionGrade.update_or_create_grade(
4345
user_id=self.user.id,
@@ -49,7 +51,7 @@ def setUp(self):
4951
earned_graded=6.0,
5052
possible_graded=8.0,
5153
visible_blocks=[],
52-
first_attempted=datetime.now(),
54+
first_attempted=datetime.now(tz=UTC),
5355
)
5456
# Mock Course
5557
self.course = MagicMock()
@@ -138,7 +140,7 @@ def test_assignment_past_due_date(
138140
mock_client.get_canvas_assignments.return_value = {
139141
"dummy-key": {
140142
"id": self.canvas_assignment_id,
141-
"due_at": str(datetime.now() - timedelta(days=1)),
143+
"due_at": str(datetime.now(tz=UTC) - timedelta(days=1)),
142144
}
143145
}
144146

0 commit comments

Comments
 (0)