Skip to content

Commit 01b9ebd

Browse files
committed
feat: add Canvas due date sync functionality
Introduce new functionality for syncing Canvas assignment due dates with Open edX. This includes: - A management command (`sync_canvas_due_dates`) for syncing due dates across courses. - New Celery task `_sync_canvas_due_dates` to update due dates on Open edX from Canvas. - New test suite (`CanvasDueDateSyncTests`) to verify due date syncing logic. - Adjustments to `run_edx_integration_tests.sh` to include `ol_openedx_canvas_integration` tests. - Extended `CanvasClient` to fetch `due_at` data for assignments. Added pytest configuration for warning filters to reduce unnecessary warnings. Previous tests for the canvas integration were not running since the integration script only runs tests in the tests folder so the tests were moved there. The previous tests were not updated after a change to `diff_assignments` in a previous PR. This went unnoticed since the tests weren't running.
1 parent 50f59f0 commit 01b9ebd

15 files changed

Lines changed: 566 additions & 109 deletions

File tree

run_edx_integration_tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ run_plugin_tests() {
179179
fi
180180

181181
# Run the pytest command with CMS settings (for ol_openedx_chat)
182-
if [[ "$plugin_dir" == *"ol_openedx_chat"* || "$plugin_dir" == *"ol_openedx_course_sync"* ]]; then
182+
if [[ "$plugin_dir" == *"ol_openedx_chat"* || "$plugin_dir" == *"ol_openedx_course_sync"* || "$plugin_dir" == *"ol_openedx_canvas_integration"* ]]; then
183183
pytest . --cov . --ds=cms.envs.test
184184

185185
PYTEST_SUCCESS=$?

src/ol_openedx_canvas_integration/CHANGELOG.rst

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ Change Log
1111
.. There should always be an "Unreleased" section for changes pending release.
1212
Unreleased
1313
~~~~~~~~~~
14+
* Corrected documentation for due date syncing direction (Canvas to Open edX).
15+
* Updated management command instructions for Tutor.
16+
* Fixed the Tutor patch example in README.rst.
17+
18+
[0.7.0] - 2026-05-12
19+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
20+
Added
21+
-----
22+
* Support for optional Canvas due date syncing.
23+
24+
[0.6.0] - 2025-10-13
25+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
26+
Changed
27+
-------
28+
* Support for Django 5.0.
29+
30+
[0.5.3] - 2025-10-08
31+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
32+
Changed
33+
-------
34+
* Use login_id to match canvas and openedx users.
35+
36+
[0.5.2] - 2025-09-19
37+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
38+
Fixed
39+
-----
40+
* Assignments are now synced with the correct published state.
1441

1542
[0.5.1] - 2025-08-26
1643
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

src/ol_openedx_canvas_integration/README.rst

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ Configuration
8181
1) Open your course in Studio.
8282
2) Navigate to "Advanced Settings".
8383
3) Enable other course settings by enabling ``ENABLE_OTHER_COURSE_SETTINGS`` feature flag in CMS
84-
4) Open course advanced settings in Open edX CMS, Add a dictionary in ``{"canvas_id": <canvas_course_id>}``. The ``canvas_course_id`` should be the id of a course that exists on Canvas. (NOTE: Canvas tab would only be visible if this value is set)
84+
4) Open course advanced settings in Open edX CMS, Add a dictionary in ``{"canvas_id": <canvas_course_id>, "use_canvas_due_dates": <True/False>}``. The ``canvas_course_id`` should be the id of a course that exists on Canvas.
85+
The ``use_canvas_due_dates`` is an optional flag (defaults to ``False``) to sync the assignment due dates from Canvas to Open edX. (NOTE: Canvas tab would only be visible if ``canvas_id`` is set)
8586

8687

8788
How To Use
@@ -115,6 +116,7 @@ Whenever the course is **Published** from the Studio, the **graded subsections**
115116
* adding new assignments when new graded subsections are added
116117
* updating the existing assignments
117118
* removing any assignment that might exist, when subsections are removed
119+
* syncing assignment due dates (if ``use_canvas_due_dates`` is set to ``True`` in course advanced settings) from Canvas to Open edX
118120

119121
.. IMPORTANT::
120122

@@ -125,3 +127,31 @@ Whenever the course is **Published** from the Studio, the **graded subsections**
125127
""""""""""""""""""""""""""""""
126128

127129
Whenever a learner interacts with a graded question in Open edX, the latest grades are automatically posted to Canvas, if it's a part of a synced assignment.
130+
131+
3. Automatic Syncing of Due Dates
132+
""""""""""""""""""""""""""""""
133+
134+
This feature allows for periodic syncing of assignment due dates from the linked Canvas course to Open edX.
135+
136+
**Scheduling the Task**
137+
138+
This sync does not happen automatically. Instead, it is triggered by a management command that needs to be scheduled as a periodic task (e.g., via cron).
139+
140+
The management command can be run (in the dev environment) using the following Tutor command:
141+
142+
.. code-block:: bash
143+
144+
tutor dev exec cms -- python manage.py cms sync_canvas_due_dates --all
145+
146+
147+
For Tutor-based installations, you can schedule this using the `grove-config` plugin which is a part
148+
of `tutor-contrib-grove <https://gitlab.com/opencraft/dev/tutor-contrib-grove#cron-jobs>`_.
149+
It can be configured using the following Tutor config snippet:
150+
151+
.. code-block:: yaml
152+
153+
GROVE_CRON_JOBS:
154+
- name: canvas-sync-due-dates
155+
service: cms
156+
script: ./manage.py cms sync_canvas_due_dates --all
157+
schedule: "0 * * * *"

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
update_grade_payload_kv,
1818
)
1919
from ol_openedx_canvas_integration.constants import COURSE_KEY_ID_EMPTY
20-
from ol_openedx_canvas_integration.utils import get_canvas_course_id
20+
from ol_openedx_canvas_integration.utils import (
21+
get_canvas_course_id,
22+
get_use_canvas_due_dates,
23+
)
2124

2225
log = logging.getLogger(__name__)
2326

@@ -227,6 +230,7 @@ def push_edx_grades_to_canvas(course):
227230
if not canvas_course_id:
228231
msg = f"No canvas_course_id set for course: {course.id}"
229232
raise Exception(msg) # noqa: TRY002
233+
use_canvas_due_dates = get_use_canvas_due_dates(course)
230234

231235
client = CanvasClient(canvas_course_id=canvas_course_id)
232236
existing_assignment_dict = client.get_canvas_assignments()
@@ -240,7 +244,7 @@ def push_edx_grades_to_canvas(course):
240244
)
241245
created_assignments = {
242246
subsection_block: client.create_canvas_assignment(
243-
create_assignment_payload(subsection_block)
247+
create_assignment_payload(subsection_block, use_canvas_due_dates)
244248
)
245249
for subsection_block in new_assignment_blocks
246250
}

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py

Lines changed: 17 additions & 9 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
@@ -246,32 +247,39 @@ def update_assignment_grades(self, canvas_assignment_id, payload):
246247
)
247248

248249

249-
def create_assignment_payload(subsection_block):
250+
def create_assignment_payload(subsection_block, use_canvas_dates=False): # noqa: FBT002
250251
"""
251252
Create a Canvas assignment dict matching a subsection block on edX
252253
253254
Args:
254255
subsection_block (openedx.core.djangoapps.content.block_structure.block_structure.BlockData):
255256
The block data for the graded assignment/exam (in the structure of a course, this unit is a subsection)
257+
use_canvas_dates (bool): Whether to use the due dates from canvas instead of Open edX
256258
257259
Returns:
258260
dict:
259261
Assignment payload to be sent to Canvas to create or update the assignment
260262
""" # noqa: E501
263+
due_date_dict = {
264+
"due_at": (
265+
None
266+
if not subsection_block.fields.get("due")
267+
# The internal API gives us a TZ-naive datetime for the due date, but Studio indicates that # noqa: E501
268+
# the user should enter a UTC datetime for the due date. Coerce this to UTC before creating the # noqa: E501
269+
# string representation.
270+
else subsection_block.fields["due"].astimezone(pytz.UTC).isoformat()
271+
),
272+
}
273+
# If we're using canvas dates, don't set the due dates from Open edX
274+
if use_canvas_dates:
275+
due_date_dict = {}
261276
return {
262277
"assignment": {
263278
"name": subsection_block.display_name,
264279
"integration_id": str(subsection_block.location),
265280
"grading_type": "percent",
266281
"points_possible": DEFAULT_ASSIGNMENT_POINTS,
267-
"due_at": (
268-
None
269-
if not subsection_block.fields.get("due")
270-
# The internal API gives us a TZ-naive datetime for the due date, but Studio indicates that # noqa: E501
271-
# the user should enter a UTC datetime for the due date. Coerce this to UTC before creating the # noqa: E501
272-
# string representation.
273-
else subsection_block.fields["due"].astimezone(pytz.UTC).isoformat()
274-
),
282+
**due_date_dict,
275283
"submission_types": ["none"],
276284
"published": False,
277285
}

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,23 +15,39 @@
1515

1616
import requests
1717
from celery import shared_task
18+
from django.utils.dateparse import parse_datetime
1819
from lms.djangoapps.courseware.courses import get_course_by_id
20+
from opaque_keys import InvalidKeyError
21+
from opaque_keys.edx.keys import CourseKey, UsageKey
1922
from opaque_keys.edx.locator import CourseLocator
23+
from xmodule.modulestore import ModuleStoreEnum
24+
from xmodule.modulestore.django import modulestore
25+
from xmodule.modulestore.exceptions import ItemNotFoundError
2026

2127
from ol_openedx_canvas_integration.api import course_graded_items
2228
from ol_openedx_canvas_integration.client import CanvasClient, create_assignment_payload
23-
from ol_openedx_canvas_integration.utils import get_canvas_course_id
29+
from ol_openedx_canvas_integration.utils import (
30+
get_canvas_course_id,
31+
get_use_canvas_due_dates,
32+
)
2433

2534
logger = logging.getLogger(__name__)
35+
TASK_LOG = logging.getLogger("edx.celery.task")
2636

2737

28-
def diff_assignments(openedx_assignments, canvas_assignments_map):
38+
def diff_assignments(
39+
openedx_assignments,
40+
canvas_assignments_map,
41+
use_canvas_dates=False, # noqa: FBT002
42+
):
2943
"""Perform a diff between the assignments in Canvas and Open edX.
3044
3145
Args:
3246
openedx_assignments (list): List of Open edX subsection objects
3347
canvas_assignments_map (dict): Map of assignment integration IDs to Canvas
3448
assignment IDs
49+
use_canvas_dates (bool): Whether to sync the due dates of the assignments
50+
with the due dates of the subsections
3551
3652
Returns:
3753
dict: The diff between the assignments with the following structure:
@@ -44,7 +60,9 @@ def diff_assignments(openedx_assignments, canvas_assignments_map):
4460
assignment_diff = {"add": [], "update": {}, "delete": []}
4561
for subsection in openedx_assignments:
4662
integration_id = str(subsection.location)
47-
payload = create_assignment_payload(subsection)
63+
payload = create_assignment_payload(
64+
subsection, use_canvas_dates=use_canvas_dates
65+
)
4866
canvas_assignment = canvas_assignments_map.pop(integration_id, None)
4967
if canvas_assignment:
5068
# if the assignment exists in Canvas, remove from the map to indicate
@@ -151,6 +169,7 @@ def sync_course_assignments_with_canvas(course_id):
151169
course_key = CourseLocator.from_string(course_id)
152170
course = get_course_by_id(course_key)
153171
canvas_course_id = get_canvas_course_id(course)
172+
use_canvas_due_dates = get_use_canvas_due_dates(course)
154173

155174
if not canvas_course_id:
156175
logger.info(
@@ -165,7 +184,9 @@ def sync_course_assignments_with_canvas(course_id):
165184
canvas = CanvasClient(canvas_course_id=canvas_course_id)
166185
canvas_assignments = canvas.get_canvas_assignments()
167186

168-
operations_map = diff_assignments(openedx_assignments, canvas_assignments)
187+
operations_map = diff_assignments(
188+
openedx_assignments, canvas_assignments, use_canvas_due_dates
189+
)
169190
logger.info(
170191
"Syncing assignments with Canvas. Adding: %d, Updating: %d, Deleting: %d",
171192
len(operations_map["add"]),
@@ -176,3 +197,84 @@ def sync_course_assignments_with_canvas(course_id):
176197
add_assignments(canvas, operations_map["add"])
177198
update_assignments(canvas, operations_map["update"])
178199
delete_assignments(canvas, operations_map["delete"])
200+
201+
202+
@shared_task
203+
def sync_canvas_due_dates(course_id: str):
204+
"""
205+
Synchronize due dates for the specified course with the Canvas platform.
206+
207+
This task is a wrapper around the `_sync_canvas_due_dates` function, which
208+
performs the actual synchronization of assignment due dates from Canvas to
209+
the platform.
210+
211+
Parameters:
212+
course_id (str): The unique identifier of the course whose due
213+
dates need to be synchronized.
214+
"""
215+
_sync_canvas_due_dates(course_id)
216+
217+
218+
def _sync_canvas_due_dates(course_id: str):
219+
"""
220+
Synchronize assignment due dates from Canvas to a specific course in the platform.
221+
222+
This function retrieves assignment due dates from Canvas associated with a
223+
given course and updates the platform's course content accordingly. The
224+
function skips synchronization if the course has no Canvas ID or if using
225+
Canvas due dates is disabled for the course.
226+
227+
Arguments:
228+
course_id (str): The unique identifier of the course to be synchronized.
229+
"""
230+
course_key = CourseKey.from_string(course_id)
231+
course = get_course_by_id(course_key)
232+
canvas_course_id = get_canvas_course_id(course)
233+
if not canvas_course_id:
234+
TASK_LOG.info(
235+
"Due Date Sync: No canvas ID. Skipped for course %s",
236+
course_id,
237+
)
238+
return
239+
use_canvas_due_dates = get_use_canvas_due_dates(course)
240+
if not use_canvas_due_dates:
241+
TASK_LOG.info(
242+
"Due Date Sync: Disabled. Skipped for course %s",
243+
course_id,
244+
)
245+
return
246+
247+
TASK_LOG.info(
248+
"Due Date Sync: Starting for course %s with canvas course id: %s",
249+
course_id,
250+
canvas_course_id,
251+
)
252+
253+
client = CanvasClient(canvas_course_id=canvas_course_id)
254+
canvas_assignments = client.get_canvas_assignments()
255+
256+
with modulestore().bulk_operations(course_key):
257+
for usage_id, canvas_assignment in canvas_assignments.items():
258+
try:
259+
usage_key = UsageKey.from_string(usage_id)
260+
due_at = canvas_assignment.get("due_at")
261+
block = modulestore().get_item(usage_key)
262+
if due_at:
263+
block.due = parse_datetime(due_at)
264+
else:
265+
block.due = None
266+
modulestore().update_item(block, ModuleStoreEnum.UserID.mgmt_command)
267+
except ItemNotFoundError:
268+
TASK_LOG.error(
269+
"Due Date Sync: Error updating due date for %s: block not found.",
270+
usage_key,
271+
)
272+
except InvalidKeyError:
273+
TASK_LOG.error(
274+
"Due Date Sync: Error updating due date for %s: invalid key.",
275+
usage_key,
276+
)
277+
except Exception as e: # noqa: BLE001
278+
TASK_LOG.error(
279+
"Due Date Sync: Error updating due date for %s: %s", usage_key, e
280+
)

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/__init__.py

Whitespace-only changes.

src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)