Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion run_edx_integration_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ run_plugin_tests() {
fi

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

PYTEST_SUCCESS=$?
Expand Down
27 changes: 27 additions & 0 deletions src/ol_openedx_canvas_integration/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ Change Log
.. There should always be an "Unreleased" section for changes pending release.
Unreleased
~~~~~~~~~~
* Corrected documentation for due date syncing direction (Canvas to Open edX).
* Updated management command instructions for Tutor.
* Fixed the Tutor patch example in README.rst.

[0.7.0] - 2026-05-12
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Added
-----
* Support for optional Canvas due date syncing.

[0.6.0] - 2025-10-13
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Changed
-------
* Support for Django 5.0.

[0.5.3] - 2025-10-08
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Changed
-------
* Use login_id to match canvas and openedx users.

[0.5.2] - 2025-09-19
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Fixed
-----
* Assignments are now synced with the correct published state.

[0.5.1] - 2025-08-26
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
32 changes: 31 additions & 1 deletion src/ol_openedx_canvas_integration/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ Configuration
1) Open your course in Studio.
2) Navigate to "Advanced Settings".
3) Enable other course settings by enabling ``ENABLE_OTHER_COURSE_SETTINGS`` feature flag in CMS
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)
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.
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)


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

.. IMPORTANT::

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

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.

3. Automatic Syncing of Due Dates
""""""""""""""""""""""""""""""

This feature allows for periodic syncing of assignment due dates from the linked Canvas course to Open edX.

**Scheduling the Task**

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).

The management command can be run (in the dev environment) using the following Tutor command:

.. code-block:: bash

tutor dev exec cms -- python manage.py cms sync_canvas_due_dates --all


For Tutor-based installations, you can schedule this using the `grove-config` plugin which is a part
of `tutor-contrib-grove <https://gitlab.com/opencraft/dev/tutor-contrib-grove#cron-jobs>`_.
It can be configured using the following Tutor config snippet:

.. code-block:: yaml

GROVE_CRON_JOBS:
- name: canvas-sync-due-dates
service: cms
script: ./manage.py cms sync_canvas_due_dates --all
schedule: "0 * * * *"
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
update_grade_payload_kv,
)
from ol_openedx_canvas_integration.constants import COURSE_KEY_ID_EMPTY
from ol_openedx_canvas_integration.utils import get_canvas_course_id
from ol_openedx_canvas_integration.utils import (
get_canvas_course_id,
get_use_canvas_due_dates,
)

log = logging.getLogger(__name__)

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

client = CanvasClient(canvas_course_id=canvas_course_id)
existing_assignment_dict = client.get_canvas_assignments()
Expand All @@ -240,7 +244,7 @@ def push_edx_grades_to_canvas(course):
)
created_assignments = {
subsection_block: client.create_canvas_assignment(
create_assignment_payload(subsection_block)
create_assignment_payload(subsection_block, use_canvas_due_dates)
)
for subsection_block in new_assignment_blocks
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def list_canvas_enrollments(self):
for enrollment in enrollments
}

def list_canvas_assignments(self):
def list_canvas_assignments(self, *args, **kwargs):
"""
List Canvas assignments

Expand All @@ -96,7 +96,7 @@ def list_canvas_assignments(self):
settings.CANVAS_BASE_URL,
f"/api/v1/courses/{self.canvas_course_id}/assignments",
)
return self._paginate(url)
return self._paginate(url, *args, **kwargs)

def get_student_id_by_email(self, email: str):
"""
Expand Down Expand Up @@ -129,6 +129,24 @@ def get_student_id_by_email(self, email: str):
cache.set(key, student_id)
return student_id

def get_emails_by_student_ids(self, student_ids: list[int]):
"""
Retrieve emails for a list of student IDs from Canvas.

Args:
student_ids (list[int]): List of student IDs.

Returns:
list[str]: List of student emails.
"""
url = urljoin(
settings.CANVAS_BASE_URL,
f"/api/v1/courses/{self.canvas_course_id}/users",
)
results = self._paginate(url, params={"user_ids[]": student_ids})

return [user["login_id"].lower() for user in results if "login_id" in user]

def get_canvas_assignments(self):
"""
Get Canvas assignments organized by integration_id.
Expand All @@ -138,11 +156,15 @@ def get_canvas_assignments(self):
'id' and 'is_published' fields. Only includes assignments with
integration_id set.
"""
assignments = self.list_canvas_assignments()
# This query param makes canvas return all overrides for each assignment
query = {"include[]": ["overrides"]}
assignments = self.list_canvas_assignments(params=query)
assignments_dict = {
assignment.get("integration_id"): {
"id": assignment["id"],
"is_published": assignment.get("published", False),
"due_at": assignment.get("due_at"),
"overrides": assignment.get("overrides", []),
}
for assignment in assignments
if assignment.get("integration_id") is not None
Expand Down Expand Up @@ -246,32 +268,39 @@ def update_assignment_grades(self, canvas_assignment_id, payload):
)


def create_assignment_payload(subsection_block):
def create_assignment_payload(subsection_block, use_canvas_dates=False): # noqa: FBT002
"""
Create a Canvas assignment dict matching a subsection block on edX

Args:
subsection_block (openedx.core.djangoapps.content.block_structure.block_structure.BlockData):
The block data for the graded assignment/exam (in the structure of a course, this unit is a subsection)
use_canvas_dates (bool): Whether to use the due dates from canvas instead of Open edX

Returns:
dict:
Assignment payload to be sent to Canvas to create or update the assignment
""" # noqa: E501
due_date_dict = {
"due_at": (
None
if not subsection_block.fields.get("due")
# The internal API gives us a TZ-naive datetime for the due date, but Studio indicates that # noqa: E501
# the user should enter a UTC datetime for the due date. Coerce this to UTC before creating the # noqa: E501
# string representation.
else subsection_block.fields["due"].astimezone(pytz.UTC).isoformat()
),
}
# If we're using canvas dates, don't set the due dates from Open edX
if use_canvas_dates:
due_date_dict = {}
return {
"assignment": {
"name": subsection_block.display_name,
"integration_id": str(subsection_block.location),
"grading_type": "percent",
"points_possible": DEFAULT_ASSIGNMENT_POINTS,
"due_at": (
None
if not subsection_block.fields.get("due")
# The internal API gives us a TZ-naive datetime for the due date, but Studio indicates that # noqa: E501
# the user should enter a UTC datetime for the due date. Coerce this to UTC before creating the # noqa: E501
# string representation.
else subsection_block.fields["due"].astimezone(pytz.UTC).isoformat()
),
**due_date_dict,
"submission_types": ["none"],
"published": False,
}
Expand All @@ -289,4 +318,4 @@ def update_grade_payload_kv(user_id, grade_percent):
Returns:
(tuple): A key/value pair that will be used in the body of a bulk grade update request
""" # noqa: D401, E501
return (f"grade_data[{user_id}][posted_grade]", f"{grade_percent * 100}%")
return f"grade_data[{user_id}][posted_grade]", f"{grade_percent * 100}%"
Loading