From 57b24bc6bd13481bb19168729a6338f2e22b76ae Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Thu, 7 May 2026 17:15:18 +0530 Subject: [PATCH 1/6] 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. --- run_edx_integration_tests.sh | 2 +- .../CHANGELOG.rst | 27 +++ src/ol_openedx_canvas_integration/README.rst | 32 ++- .../ol_openedx_canvas_integration/api.py | 8 +- .../ol_openedx_canvas_integration/client.py | 26 ++- .../cms_tasks.py | 110 ++++++++- .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/sync_canvas_due_dates.py | 87 +++++++ .../test_cms_tasks.py | 86 ------- .../ol_openedx_canvas_integration/utils.py | 9 + .../pyproject.toml | 11 +- .../tests/__init__.py | 0 .../tests/test_cms_tasks.py | 213 ++++++++++++++++++ uv.lock | 2 +- 15 files changed, 508 insertions(+), 105 deletions(-) create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/__init__.py create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/__init__.py create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py delete mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py create mode 100644 src/ol_openedx_canvas_integration/tests/__init__.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_cms_tasks.py diff --git a/run_edx_integration_tests.sh b/run_edx_integration_tests.sh index 059f7f250..bd9b10418 100755 --- a/run_edx_integration_tests.sh +++ b/run_edx_integration_tests.sh @@ -193,7 +193,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=$? diff --git a/src/ol_openedx_canvas_integration/CHANGELOG.rst b/src/ol_openedx_canvas_integration/CHANGELOG.rst index b2e2205ed..5b0707890 100644 --- a/src/ol_openedx_canvas_integration/CHANGELOG.rst +++ b/src/ol_openedx_canvas_integration/CHANGELOG.rst @@ -12,6 +12,33 @@ Change Log 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 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ol_openedx_canvas_integration/README.rst b/src/ol_openedx_canvas_integration/README.rst index a7b08a8fa..4536e817d 100644 --- a/src/ol_openedx_canvas_integration/README.rst +++ b/src/ol_openedx_canvas_integration/README.rst @@ -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": }``. 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": , "use_canvas_due_dates": }``. 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 @@ -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:: @@ -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 `_. +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 * * * *" diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py index 239146e54..270625f45 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py @@ -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__) @@ -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() @@ -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 } diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py index 385a3564a..d910d198b 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py @@ -143,6 +143,7 @@ def get_canvas_assignments(self): assignment.get("integration_id"): { "id": assignment["id"], "is_published": assignment.get("published", False), + "due_at": assignment.get("due_at"), } for assignment in assignments if assignment.get("integration_id") is not None @@ -246,32 +247,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, } diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py index 18ff3437a..c97565a3d 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py @@ -15,23 +15,39 @@ import requests from celery import shared_task +from django.utils.dateparse import parse_datetime from lms.djangoapps.courseware.courses import get_course_by_id +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import CourseLocator +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError from ol_openedx_canvas_integration.api import course_graded_items from ol_openedx_canvas_integration.client import CanvasClient, create_assignment_payload -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, +) logger = logging.getLogger(__name__) +TASK_LOG = logging.getLogger("edx.celery.task") -def diff_assignments(openedx_assignments, canvas_assignments_map): +def diff_assignments( + openedx_assignments, + canvas_assignments_map, + use_canvas_dates=False, # noqa: FBT002 +): """Perform a diff between the assignments in Canvas and Open edX. Args: openedx_assignments (list): List of Open edX subsection objects canvas_assignments_map (dict): Map of assignment integration IDs to Canvas assignment IDs + use_canvas_dates (bool): Whether to sync the due dates of the assignments + with the due dates of the subsections Returns: dict: The diff between the assignments with the following structure: @@ -44,7 +60,9 @@ def diff_assignments(openedx_assignments, canvas_assignments_map): assignment_diff = {"add": [], "update": {}, "delete": []} for subsection in openedx_assignments: integration_id = str(subsection.location) - payload = create_assignment_payload(subsection) + payload = create_assignment_payload( + subsection, use_canvas_dates=use_canvas_dates + ) canvas_assignment = canvas_assignments_map.pop(integration_id, None) if canvas_assignment: # if the assignment exists in Canvas, remove from the map to indicate @@ -151,6 +169,7 @@ def sync_course_assignments_with_canvas(course_id): course_key = CourseLocator.from_string(course_id) course = get_course_by_id(course_key) canvas_course_id = get_canvas_course_id(course) + use_canvas_due_dates = get_use_canvas_due_dates(course) if not canvas_course_id: logger.info( @@ -165,7 +184,9 @@ def sync_course_assignments_with_canvas(course_id): canvas = CanvasClient(canvas_course_id=canvas_course_id) canvas_assignments = canvas.get_canvas_assignments() - operations_map = diff_assignments(openedx_assignments, canvas_assignments) + operations_map = diff_assignments( + openedx_assignments, canvas_assignments, use_canvas_due_dates + ) logger.info( "Syncing assignments with Canvas. Adding: %d, Updating: %d, Deleting: %d", len(operations_map["add"]), @@ -176,3 +197,84 @@ def sync_course_assignments_with_canvas(course_id): add_assignments(canvas, operations_map["add"]) update_assignments(canvas, operations_map["update"]) delete_assignments(canvas, operations_map["delete"]) + + +@shared_task +def sync_canvas_due_dates(course_id: str): + """ + Synchronize due dates for the specified course with the Canvas platform. + + This task is a wrapper around the `_sync_canvas_due_dates` function, which + performs the actual synchronization of assignment due dates from Canvas to + the platform. + + Parameters: + course_id (str): The unique identifier of the course whose due + dates need to be synchronized. + """ + _sync_canvas_due_dates(course_id) + + +def _sync_canvas_due_dates(course_id: str): + """ + Synchronize assignment due dates from Canvas to a specific course in the platform. + + This function retrieves assignment due dates from Canvas associated with a + given course and updates the platform's course content accordingly. The + function skips synchronization if the course has no Canvas ID or if using + Canvas due dates is disabled for the course. + + Arguments: + course_id (str): The unique identifier of the course to be synchronized. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + canvas_course_id = get_canvas_course_id(course) + if not canvas_course_id: + TASK_LOG.info( + "Due Date Sync: No canvas ID. Skipped for course %s", + course_id, + ) + return + use_canvas_due_dates = get_use_canvas_due_dates(course) + if not use_canvas_due_dates: + TASK_LOG.info( + "Due Date Sync: Disabled. Skipped for course %s", + course_id, + ) + return + + TASK_LOG.info( + "Due Date Sync: Starting for course %s with canvas course id: %s", + course_id, + canvas_course_id, + ) + + client = CanvasClient(canvas_course_id=canvas_course_id) + canvas_assignments = client.get_canvas_assignments() + + with modulestore().bulk_operations(course_key): + for usage_id, canvas_assignment in canvas_assignments.items(): + try: + usage_key = UsageKey.from_string(usage_id) + due_at = canvas_assignment.get("due_at") + block = modulestore().get_item(usage_key) + if due_at: + block.due = parse_datetime(due_at) + else: + block.due = None + modulestore().update_item(block, ModuleStoreEnum.UserID.mgmt_command) + except ItemNotFoundError: + TASK_LOG.error( + "Due Date Sync: Error updating due date for %s: block not found.", + usage_key, + ) + except InvalidKeyError: + TASK_LOG.error( + "Due Date Sync: Error updating due date for %s: invalid key.", + usage_key, + ) + except Exception as e: # noqa: BLE001 + TASK_LOG.error( + "Due Date Sync: Error updating due date for %s: %s", usage_key, e + ) diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/__init__.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/__init__.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py new file mode 100644 index 000000000..249620515 --- /dev/null +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py @@ -0,0 +1,87 @@ +""" +Script for syncing Canvas due dates with Open edX +""" + +import logging +from pathlib import Path + +from django.core.management.base import BaseCommand +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from ol_openedx_canvas_integration.cms_tasks import sync_canvas_due_dates + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Sync Canvas due dates with Open edX + """ + + help = "Sync Canvas due dates with Open edX" + + def add_arguments(self, parser): + parser.add_argument( + "course_keys", + nargs="*", + type=str, + help="List of courses to sync due dates", + ) + parser.add_argument( + "--all", + action="store_true", + help="Sync all courses in the system", + ) + + def handle(self, *args, **options): # noqa: ARG002 + course_keys = options.get("course_keys", []) + all_courses = options.get("all") + + if not course_keys and not all_courses: + # Either course_keys or --all flag must be provided + command_name = Path(__file__).stem + self.stderr.write( + self.style.ERROR( + "Error: You must specify either course keys or use the --all flag.\n" # noqa: E501 + "Examples:\n" + f" python manage.py {command_name} --all\n" + f" python manage.py {command_name} course-v1:MITx+6.00x+2T2024" + ) + ) + return + + # Both course_keys and --all flag cannot be used together + if course_keys and all_courses: + self.stderr.write( + self.style.ERROR( + "Error: Cannot use both course keys and --all flag together. " + "Please use one or the other." + ) + ) + return + + courses = ( + CourseOverview.objects.all() + .order_by("created") + .values_list("id", flat=True) + ) + if course_keys: + courses = courses.filter(id__in=course_keys) + self.stdout.write( + self.style.SUCCESS( + f"Processing {len(course_keys)} specified course(s)..." + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Processing all {courses.count()} course(s) in the system..." + ) + ) + for course_id in courses: + try: + sync_canvas_due_dates.delay(str(course_id)) + except Exception as ex: # noqa: BLE001 + self.stderr.write( + self.style.ERROR(f"Error processing course {course_id}: {ex!s}") + ) diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py deleted file mode 100644 index bc2cc94a5..000000000 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/test_cms_tasks.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import pytest - -from ol_openedx_canvas_integration.api import create_assignment_payload -from ol_openedx_canvas_integration.cms_tasks import diff_assignments - - -class MockSubsection: - def __init__(self, location) -> None: - self.location = location - self.display_name = "Mock Assignment in " + str(location) - self.fields: dict[str, str] = {} - - @property - def payload(self): - return create_assignment_payload(self) - - -subsection_mocks = [MockSubsection(f"id-{i}") for i in range(10)] - - -@pytest.mark.parametrize( - ("openedx_assignments", "canvas_assignments_map", "expected_output"), - [ - # All empty - ([], {}, {"add": [], "update": {}, "delete": []}), - # Add new assignments to Canvas - ( - subsection_mocks[0:3], - {}, - { - "add": [s.payload for s in subsection_mocks[0:3]], - "update": {}, - "delete": [], - }, - ), - # Update existing assignments - ( - subsection_mocks[8:], - { - "id-8": 1008, - "id-9": 1009, - }, - { - "add": [], - "update": { - 1008: subsection_mocks[8].payload, - 1009: subsection_mocks[9].payload, - }, - "delete": [], - }, - ), - # Remove existing assignments - ( - [], - { - "synced-1": 1002, - "synced-2": 1003, - }, - {"add": [], "update": {}, "delete": [1002, 1003]}, - ), - # Add some, update some and remove some assignments - ( - subsection_mocks[4:8], - { - "id-2": 12, # remove - "id-3": 13, # remove - "id-4": 14, # update - "id-5": 15, # update - }, - { - "add": [s.payload for s in subsection_mocks[6:8]], - "update": { - 14: subsection_mocks[4].payload, - 15: subsection_mocks[5].payload, - }, - "delete": [12, 13], - }, - ), - ], -) -def test_diff_assignments(openedx_assignments, canvas_assignments_map, expected_output): - assert ( - diff_assignments(openedx_assignments, canvas_assignments_map) == expected_output - ) diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py index 3978d6816..67deb87df 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py @@ -6,6 +6,15 @@ def get_canvas_course_id(course=None): return course.other_course_settings.get("canvas_id") if course else None +def get_use_canvas_due_dates(course=None): + """Get the canvas due dates setting from the course settings""" + return ( + course.other_course_settings.get("use_canvas_due_dates", False) + if course + else None + ) + + def get_task_output_formatted_message(task_output): """Take the edX task output and format a message for table display on task result""" # this reports on actions for a course as a whole diff --git a/src/ol_openedx_canvas_integration/pyproject.toml b/src/ol_openedx_canvas_integration/pyproject.toml index e1b58e8d3..f8bf36051 100644 --- a/src/ol_openedx_canvas_integration/pyproject.toml +++ b/src/ol_openedx_canvas_integration/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ol-openedx-canvas-integration" -version = "0.6.0" +version = "0.7.0" description = "An Open edX plugin to add canvas integration support" authors = [ {name = "MIT Office of Digital Learning"} @@ -48,3 +48,12 @@ include = [ exclude = [ "ol_openedx_canvas_integration/**/test_*" ] + +[tool.pytest] +addopts = ["--nomigrations", "--reuse-db"] +filterwarnings = [ + "default", + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning", + "ignore:.*pkg_resources.*:DeprecationWarning", + "ignore:.*Python 3.13.*:DeprecationWarning", +] diff --git a/src/ol_openedx_canvas_integration/tests/__init__.py b/src/ol_openedx_canvas_integration/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py new file mode 100644 index 000000000..40890303a --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import ddt +import pytest +from django.test import override_settings +from django.utils.dateparse import parse_datetime +from ol_openedx_canvas_integration.api import create_assignment_payload +from ol_openedx_canvas_integration.cms_tasks import ( + _sync_canvas_due_dates, + diff_assignments, +) +from opaque_keys.edx.keys import UsageKey +from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + + +class MockSubsection: + def __init__(self, location) -> None: + self.location = location + self.display_name = "Mock Assignment in " + str(location) + self.fields: dict[str, str] = {} + + @property + def payload(self): + return create_assignment_payload(self) + + +subsection_mocks = [MockSubsection(f"id-{i}") for i in range(10)] + + +def make_assignment(assignment_id: int, due_at: str | None = None): + return { + "id": assignment_id, + "due_at": due_at, + } + + +@skip_unless_cms +@pytest.mark.parametrize( + ("openedx_assignments", "canvas_assignments_map", "expected_output"), + [ + # All empty + ([], {}, {"add": [], "update": {}, "delete": []}), + # Add new assignments to Canvas + ( + subsection_mocks[0:3], + {}, + { + "add": [s.payload for s in subsection_mocks[0:3]], + "update": {}, + "delete": [], + }, + ), + # Update existing assignments + ( + subsection_mocks[8:], + { + "id-8": make_assignment(1008), + "id-9": make_assignment(1009), + }, + { + "add": [], + "update": { + 1008: subsection_mocks[8].payload, + 1009: subsection_mocks[9].payload, + }, + "delete": [], + }, + ), + # Remove existing assignments + ( + [], + { + "synced-1": make_assignment(1002), + "synced-2": make_assignment(1003), + }, + {"add": [], "update": {}, "delete": [1002, 1003]}, + ), + # Add some, update some and remove some assignments + ( + subsection_mocks[4:8], + { + "id-2": make_assignment(12), # remove + "id-3": make_assignment(13), # remove + "id-4": make_assignment(14), # update + "id-5": make_assignment(15), # update + }, + { + "add": [s.payload for s in subsection_mocks[6:8]], + "update": { + 14: subsection_mocks[4].payload, + 15: subsection_mocks[5].payload, + }, + "delete": [12, 13], + }, + ), + ], +) +def test_diff_assignments(openedx_assignments, canvas_assignments_map, expected_output): + assert ( + diff_assignments(openedx_assignments, canvas_assignments_map) == expected_output + ) + + +@skip_unless_cms +@override_settings(BULK_EMAIL_DEFAULT_RETRY_DELAY=10, BULK_EMAIL_MAX_RETRIES=5) +@ddt.ddt +class CanvasDueDateSyncTests(ModuleStoreTestCase): + def setUp(self): + super().setUp() + + def create_course(self, other_course_settings: dict | None = None): + if other_course_settings is None: + other_course_settings = {} + course = CourseFactory.create(other_course_settings=other_course_settings) + chapter = BlockFactory.create( + parent=course, + category="chapter", + display_name="Chapter", + ) + sequential1 = BlockFactory.create( + parent=chapter, + category="sequential", + display_name="Lesson 1", + ) + BlockFactory.create( + parent=sequential1, + category="vertical", + display_name="Subsection 1", + ) + sequential2 = BlockFactory.create( + parent=chapter, + category="sequential", + display_name="Lesson 2", + ) + BlockFactory.create( + parent=sequential2, + category="vertical", + display_name="Subsection 2", + ) + return course, [sequential1, sequential2] + + @ddt.data( + {"canvas_id": ""}, + {"canvas_id": None}, + {}, + ) + def test_sync_canvas_due_dates_no_canvas_id(self, other_course_settings): + course, _ = self.create_course(other_course_settings) + canvas_client_mock = MagicMock() + + with ( + patch( + "ol_openedx_canvas_integration.cms_tasks.CanvasClient", + return_value=canvas_client_mock, + ), + ): + _sync_canvas_due_dates(str(course.id)) + + canvas_client_mock.get_canvas_assignments.assert_not_called() + + @ddt.data( + {}, + {"use_canvas_due_dates": False}, + ) + def test_sync_canvas_due_dates_due_dates_disabled(self, other_course_settings): + course, _ = self.create_course( + { + "canvas_id": 11, + **other_course_settings, + } + ) + canvas_client_mock = MagicMock() + + with ( + patch( + "ol_openedx_canvas_integration.cms_tasks.CanvasClient", + return_value=canvas_client_mock, + ), + ): + _sync_canvas_due_dates(str(course.id)) + + canvas_client_mock.get_canvas_assignments.assert_not_called() + + def test_sync_canvas_due_dates_updates_due_dates(self): + course, sequentials = self.create_course( + {"canvas_id": 11, "use_canvas_due_dates": True} + ) + + mock_canvas_assignments = { + str(sequentials[0].location): {"due_at": "2026-06-01T00:00:00Z"}, + str(sequentials[1].location): {"due_at": None}, + } + + canvas_client_mock = MagicMock() + canvas_client_mock.get_canvas_assignments.return_value = mock_canvas_assignments + + with ( + patch( + "ol_openedx_canvas_integration.cms_tasks.CanvasClient", + return_value=canvas_client_mock, + ), + ): + _sync_canvas_due_dates(str(course.id)) + + for seq_id, data in mock_canvas_assignments.items(): + seq_key = UsageKey.from_string(seq_id) + due_at = data.get("due_at", None) + due_at = parse_datetime(due_at) if due_at else None + assert self.store.get_item(seq_key).due == due_at diff --git a/uv.lock b/uv.lock index c812adaa9..dbd54addb 100644 --- a/uv.lock +++ b/uv.lock @@ -2103,7 +2103,7 @@ requires-dist = [ [[package]] name = "ol-openedx-canvas-integration" -version = "0.6.0" +version = "0.7.0" source = { editable = "src/ol_openedx_canvas_integration" } dependencies = [ { name = "celery" }, From c7c3eb530639043f9f650698993c2444db3f9c27 Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Wed, 20 May 2026 00:07:37 +0530 Subject: [PATCH 2/6] 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`. --- .../ol_openedx_canvas_integration/tasks.py | 19 ++ .../pyproject.toml | 4 +- .../tests/conftest.py | 15 ++ .../tests/test_tasks.py | 234 ++++++++++++++++++ 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/ol_openedx_canvas_integration/tests/conftest.py create mode 100644 src/ol_openedx_canvas_integration/tests/test_tasks.py diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py index e2fd7a56e..79d81509d 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py @@ -2,11 +2,13 @@ import hashlib import logging +from datetime import datetime, timezone from functools import partial import requests from celery import shared_task from django.contrib.auth import get_user_model +from django.utils.dateparse import parse_datetime from lms.djangoapps.courseware.courses import get_course_by_id from lms.djangoapps.grades.models import PersistentSubsectionGrade 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): @shared_task def sync_user_grade_with_canvas(grade_id): + """ + Call the Canvas API and update the user's grade. + """ + _sync_user_grade_with_canvas(grade_id) + + +def _sync_user_grade_with_canvas(grade_id): """ Call the Canvas API and update the user's grade. """ @@ -110,6 +119,16 @@ def sync_user_grade_with_canvas(grade_id): ) return + due_date = parse_datetime( + existing_assignments_map[str(grade_instance.usage_key)]["due_at"] + ) + if due_date and due_date < datetime.now(tz=timezone.utc): + TASK_LOG.warning( + "The assignment %s is past its due date. Skipping grade sync.", + grade_instance.usage_key, + ) + return + canvas_assignment_id = existing_assignments_map[str(grade_instance.usage_key)]["id"] openedx_user = USER_MODEL.objects.get(id=grade_instance.user_id) canvas_user_id = client.get_student_id_by_email(openedx_user.email) diff --git a/src/ol_openedx_canvas_integration/pyproject.toml b/src/ol_openedx_canvas_integration/pyproject.toml index f8bf36051..42b834546 100644 --- a/src/ol_openedx_canvas_integration/pyproject.toml +++ b/src/ol_openedx_canvas_integration/pyproject.toml @@ -49,8 +49,8 @@ exclude = [ "ol_openedx_canvas_integration/**/test_*" ] -[tool.pytest] -addopts = ["--nomigrations", "--reuse-db"] +[tool.pytest.ini_options] +addopts = ["--no-migrations", "--reuse-db"] filterwarnings = [ "default", "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning", diff --git a/src/ol_openedx_canvas_integration/tests/conftest.py b/src/ol_openedx_canvas_integration/tests/conftest.py new file mode 100644 index 000000000..cce50ac2e --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/conftest.py @@ -0,0 +1,15 @@ +""" +Pytest configuration for ol_openedx_canvas_integration tests. +""" + +from django.conf import settings + + +def pytest_configure(): + """Pytest hook that runs after command line options have been parsed""" + + # Add additional Django settings needed for the plugin tests + if not hasattr(settings, "BULK_EMAIL_DEFAULT_RETRY_DELAY"): + settings.BULK_EMAIL_DEFAULT_RETRY_DELAY = 10 + if not hasattr(settings, "BULK_EMAIL_MAX_RETRIES"): + settings.BULK_EMAIL_MAX_RETRIES = 5 diff --git a/src/ol_openedx_canvas_integration/tests/test_tasks.py b/src/ol_openedx_canvas_integration/tests/test_tasks.py new file mode 100644 index 000000000..166049bd1 --- /dev/null +++ b/src/ol_openedx_canvas_integration/tests/test_tasks.py @@ -0,0 +1,234 @@ +"""Tests for Canvas integration tasks""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +import requests +from django.contrib.auth import get_user_model +from django.test import override_settings, TestCase +from opaque_keys.edx.keys import UsageKey, CourseKey + +from ol_openedx_canvas_integration.tasks import _sync_user_grade_with_canvas +from lms.djangoapps.grades.models import PersistentSubsectionGrade +from openedx.core.djangolib.testing.utils import skip_unless_lms + +USER_MODEL = get_user_model() + + +@override_settings(BULK_EMAIL_DEFAULT_RETRY_DELAY=10, BULK_EMAIL_MAX_RETRIES=5) +@patch("ol_openedx_canvas_integration.tasks.submit_task", MagicMock(return_value=None)) +@patch( + "ol_openedx_canvas_integration.tasks.get_course_by_id", + MagicMock(return_value=MagicMock()), +) +@skip_unless_lms +class TestSyncUserGradeWithCanvas(TestCase): + """Tests for _sync_user_grade_with_canvas task""" + + def setUp(self): + """Setup test data""" + self.grade_id = 123 + self.course_id = CourseKey.from_string("course-v1:org+course+run") + self.usage_key = UsageKey.from_string( + "block-v1:org+course+run+type@sequential+block@subsection" + ) + self.canvas_course_id = "canvas-123" + self.email = "student@example.com" + self.canvas_user_id = 456 + self.canvas_assignment_id = 789 + self.user = USER_MODEL.objects.create_user( + username="student", email="student@example.com", password="password" + ) + self.grade_instance = PersistentSubsectionGrade.update_or_create_grade( + user_id=self.user.id, + id=self.grade_id, + course_id=self.course_id, + usage_key=self.usage_key, + earned_all=6.0, + possible_all=12.0, + earned_graded=6.0, + possible_graded=8.0, + visible_blocks=[], + first_attempted=datetime.now(), + ) + # Mock Course + self.course = MagicMock() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades") + @patch("ol_openedx_canvas_integration.tasks.TASK_LOG") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value="canvas-123"), + ) + def test_sync_success( + self, + mock_task_log, + mock_get_grades, + mock_client_class, + ): + """Test successful grade sync to Canvas""" + mock_client = mock_client_class.return_value + mock_client.get_canvas_assignments.return_value = { + str(self.usage_key): { + "id": self.canvas_assignment_id, + "due_at": "", + } + } + mock_client.get_student_id_by_email.return_value = self.canvas_user_id + mock_client.update_assignment_grades.return_value = MagicMock( + status_code=requests.codes.ok + ) + + grade_obj = MagicMock() + grade_obj.percent_graded = 0.85 + mock_get_grades.return_value = {self.usage_key: {self.user: grade_obj}} + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client.update_assignment_grades.assert_called_once() + + mock_task_log.error.assert_not_called() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value=None), + ) + def test_no_canvas_id( + self, + mock_client_class, + ): + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client_class.assert_not_called() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value="canvas-123"), + ) + def test_assignment_not_synced( + self, + mock_client_class, + ): + mock_client = mock_client_class.return_value + mock_client.get_canvas_assignments.return_value = { + "dummy-key": { + "id": self.canvas_assignment_id, + "due_at": "", + } + } + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client.update_assignment_grades.assert_not_called() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value="canvas-123"), + ) + def test_assignment_past_due_date( + self, + mock_client_class, + ): + mock_client = mock_client_class.return_value + mock_client.get_canvas_assignments.return_value = { + "dummy-key": { + "id": self.canvas_assignment_id, + "due_at": str(datetime.now() - timedelta(days=1)), + } + } + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client.update_assignment_grades.assert_not_called() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value="canvas-123"), + ) + def test_no_canvas_user_id( + self, + mock_client_class, + ): + mock_client = mock_client_class.return_value + mock_client.get_canvas_assignments.return_value = { + str(self.usage_key): { + "id": self.canvas_assignment_id, + "due_at": "", + } + } + mock_client.get_student_id_by_email.return_value = None + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client.update_assignment_grades.assert_not_called() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value="canvas-123"), + ) + def test_sync_key_error( + self, + mock_get_grades, + mock_client_class, + ): + """Test successful grade sync to Canvas""" + mock_client = mock_client_class.return_value + mock_client.get_canvas_assignments.return_value = { + str(self.usage_key): { + "id": self.canvas_assignment_id, + "due_at": "", + } + } + mock_client.get_student_id_by_email.return_value = self.canvas_user_id + mock_client.update_assignment_grades.return_value = MagicMock( + status_code=requests.codes.ok + ) + + grade_obj = MagicMock() + grade_obj.percent_graded = 0.85 + mock_get_grades.return_value = {"dummy-key": {self.user: grade_obj}} + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client.update_assignment_grades.assert_not_called() + + @patch("ol_openedx_canvas_integration.tasks.CanvasClient") + @patch("ol_openedx_canvas_integration.tasks.get_subsection_user_grades") + @patch("ol_openedx_canvas_integration.tasks.TASK_LOG") + @patch( + "ol_openedx_canvas_integration.tasks.get_canvas_course_id", + MagicMock(return_value="canvas-123"), + ) + def test_sync_fail_code( + self, + mock_task_log, + mock_get_grades, + mock_client_class, + ): + mock_client = mock_client_class.return_value + mock_client.get_canvas_assignments.return_value = { + str(self.usage_key): { + "id": self.canvas_assignment_id, + "due_at": "", + } + } + mock_client.get_student_id_by_email.return_value = self.canvas_user_id + mock_client.update_assignment_grades.return_value = MagicMock(status_code=502) + + grade_obj = MagicMock() + grade_obj.percent_graded = 0.85 + mock_get_grades.return_value = {self.usage_key: {self.user: grade_obj}} + + _sync_user_grade_with_canvas(self.grade_id) + + mock_client.update_assignment_grades.assert_called_once() + + mock_task_log.error.assert_called_once() From a35eff5e16032df2fc83f62bff75aaca6f7e24ff Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Wed, 20 May 2026 00:07:37 +0530 Subject: [PATCH 3/6] 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. --- .../ol_openedx_canvas_integration/client.py | 29 ++++++-- .../cms_tasks.py | 38 ++++++++++ .../ol_openedx_canvas_integration/tasks.py | 4 +- .../tests/test_cms_tasks.py | 71 ++++++++++++++++++- .../tests/test_tasks.py | 16 +++-- 5 files changed, 144 insertions(+), 14 deletions(-) diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py index d910d198b..cb8d66fa4 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py @@ -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 @@ -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): """ @@ -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. @@ -138,12 +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 @@ -297,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}%" diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py index c97565a3d..dce87b35d 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py @@ -15,8 +15,10 @@ import requests from celery import shared_task +from django.contrib.auth import get_user_model from django.utils.dateparse import parse_datetime from lms.djangoapps.courseware.courses import get_course_by_id +from lms.djangoapps.instructor.views.tools import set_due_date_extension from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import CourseLocator @@ -33,6 +35,7 @@ logger = logging.getLogger(__name__) TASK_LOG = logging.getLogger("edx.celery.task") +User = get_user_model() def diff_assignments( @@ -215,6 +218,38 @@ def sync_canvas_due_dates(course_id: str): _sync_canvas_due_dates(course_id) +def sync_canvas_due_date_extensions(client, course, block, overrides): + """ + Synchronize due date extensions for students in Canvas with the platform. + + Parameters: + client (CanvasAPIClient): The Canvas API client for making requests. + course (Course): Course object for which due date extensions are being synced. + block (Block): Block object for which due date extensions are being synced. + overrides (list): List of due date overrides from Canvas. + """ + if not overrides: + return + canvas_course_id = get_canvas_course_id(course) + for override in overrides: + if "student_ids" in override: + emails = client.get_emails_by_student_ids(override["student_ids"]) + students = User.objects.filter(email__in=emails) + for student in students: + TASK_LOG.info( + "Due Date Sync: Syncing due date for student %s in course %s", + student.id, + course.id, + ) + set_due_date_extension( + course, + block, + student, + override["due_at"], + reason=f"Synced from canvas course: {canvas_course_id}", + ) + + def _sync_canvas_due_dates(course_id: str): """ 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): usage_key = UsageKey.from_string(usage_id) due_at = canvas_assignment.get("due_at") block = modulestore().get_item(usage_key) + sync_canvas_due_date_extensions( + client, course, block, canvas_assignment.get("overrides") + ) if due_at: block.due = parse_datetime(due_at) else: diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py index 79d81509d..eb84c9f1d 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py @@ -2,7 +2,7 @@ import hashlib import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from functools import partial import requests @@ -122,7 +122,7 @@ def _sync_user_grade_with_canvas(grade_id): due_date = parse_datetime( existing_assignments_map[str(grade_instance.usage_key)]["due_at"] ) - if due_date and due_date < datetime.now(tz=timezone.utc): + if due_date and due_date < datetime.now(tz=UTC): TASK_LOG.warning( "The assignment %s is past its due date. Skipping grade sync.", grade_instance.usage_key, diff --git a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py index 40890303a..d9e56d9f2 100644 --- a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py +++ b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py @@ -1,9 +1,10 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import ddt import pytest +from django.contrib.auth.models import User from django.test import override_settings from django.utils.dateparse import parse_datetime from ol_openedx_canvas_integration.api import create_assignment_payload @@ -211,3 +212,71 @@ def test_sync_canvas_due_dates_updates_due_dates(self): due_at = data.get("due_at", None) due_at = parse_datetime(due_at) if due_at else None assert self.store.get_item(seq_key).due == due_at + + def test_sync_canvas_due_date_extensions(self): + for uid in [1, 4, 9, 11, 14, 37]: + User.objects.create_user(f"user{uid}", f"user{uid}@abc.xyz", "password") + course, sequentials = self.create_course( + {"canvas_id": 11, "use_canvas_due_dates": True} + ) + + sequential_0_student_ids = [11, 37, 4] + sequential_1_student_ids = [1, 9, 14] + + mock_canvas_assignments = { + str(sequentials[0].location): { + "due_at": "2026-06-01T00:00:00Z", + "overrides": [ + { + "due_at": "2026-06-02T00:00:00Z", + "student_ids": sequential_0_student_ids, + } + ], + }, + str(sequentials[1].location): { + "due_at": None, + "overrides": [ + { + "due_at": "2026-06-04T00:00:00Z", + "student_ids": sequential_1_student_ids, + } + ], + }, + } + + canvas_client_mock = MagicMock() + canvas_client_mock.get_canvas_assignments.return_value = mock_canvas_assignments + canvas_client_mock.get_emails_by_student_ids.side_effect = lambda ids: [ + f"user{uid}@abc.xyz" for uid in ids + ] + + with ( + patch( + "ol_openedx_canvas_integration.cms_tasks.CanvasClient", + return_value=canvas_client_mock, + ), + patch( + "ol_openedx_canvas_integration.cms_tasks.set_due_date_extension" + ) as set_due_date_extension_mock, + ): + _sync_canvas_due_dates(str(course.id)) + # 3 student extensions each for 2 assignments + assert set_due_date_extension_mock.call_count == ( + len(sequential_0_student_ids) + len(sequential_1_student_ids) + ) + for call_args, _ in set_due_date_extension_mock.call_args_list: + assert call_args[0].id == course.id + assert call_args[1].location in ( + sequentials[0].location, + sequentials[1].location, + ) + + for student_id in sequential_0_student_ids: + for _ in sequentials: + set_due_date_extension_mock.assert_any_call( + ANY, + ANY, + User.objects.get(email=f"user{student_id}@abc.xyz"), + "2026-06-02T00:00:00Z", + reason="Synced from canvas course: 11", + ) diff --git a/src/ol_openedx_canvas_integration/tests/test_tasks.py b/src/ol_openedx_canvas_integration/tests/test_tasks.py index 166049bd1..3a1609eaa 100644 --- a/src/ol_openedx_canvas_integration/tests/test_tasks.py +++ b/src/ol_openedx_canvas_integration/tests/test_tasks.py @@ -5,12 +5,12 @@ import requests from django.contrib.auth import get_user_model -from django.test import override_settings, TestCase -from opaque_keys.edx.keys import UsageKey, CourseKey - -from ol_openedx_canvas_integration.tasks import _sync_user_grade_with_canvas +from django.test import TestCase, override_settings from lms.djangoapps.grades.models import PersistentSubsectionGrade +from ol_openedx_canvas_integration.tasks import _sync_user_grade_with_canvas +from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangolib.testing.utils import skip_unless_lms +from pytz import UTC USER_MODEL = get_user_model() @@ -37,7 +37,9 @@ def setUp(self): self.canvas_user_id = 456 self.canvas_assignment_id = 789 self.user = USER_MODEL.objects.create_user( - username="student", email="student@example.com", password="password" + username="student", + email="student@example.com", + password="password", # noqa: S106 # pragma: allowlist secret ) self.grade_instance = PersistentSubsectionGrade.update_or_create_grade( user_id=self.user.id, @@ -49,7 +51,7 @@ def setUp(self): earned_graded=6.0, possible_graded=8.0, visible_blocks=[], - first_attempted=datetime.now(), + first_attempted=datetime.now(tz=UTC), ) # Mock Course self.course = MagicMock() @@ -138,7 +140,7 @@ def test_assignment_past_due_date( mock_client.get_canvas_assignments.return_value = { "dummy-key": { "id": self.canvas_assignment_id, - "due_at": str(datetime.now() - timedelta(days=1)), + "due_at": str(datetime.now(tz=UTC) - timedelta(days=1)), } } From ffa2bd3c2e9ded100902bafee37caee072e752ab Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 26 May 2026 09:03:35 +0530 Subject: [PATCH 4/6] fix: correct usage key handling and improve due date parsing Updated error logging in `cms_tasks` to use `usage_id` instead of `usage_key` for consistency. Adjusted due date parsing in `tasks` to handle cases where due date is missing, ensuring safer datetime operations. --- .../ol_openedx_canvas_integration/cms_tasks.py | 9 +++++---- .../ol_openedx_canvas_integration/tasks.py | 6 +++--- .../tests/test_cms_tasks.py | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py index dce87b35d..2e3e8acbd 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py @@ -241,11 +241,12 @@ def sync_canvas_due_date_extensions(client, course, block, overrides): student.id, course.id, ) + due_date_override = parse_datetime(override["due_at"]) set_due_date_extension( course, block, student, - override["due_at"], + due_date_override, reason=f"Synced from canvas course: {canvas_course_id}", ) @@ -305,14 +306,14 @@ def _sync_canvas_due_dates(course_id: str): except ItemNotFoundError: TASK_LOG.error( "Due Date Sync: Error updating due date for %s: block not found.", - usage_key, + usage_id, ) except InvalidKeyError: TASK_LOG.error( "Due Date Sync: Error updating due date for %s: invalid key.", - usage_key, + usage_id, ) except Exception as e: # noqa: BLE001 TASK_LOG.error( - "Due Date Sync: Error updating due date for %s: %s", usage_key, e + "Due Date Sync: Error updating due date for %s: %s", usage_id, e ) diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py index eb84c9f1d..85555fe7e 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/tasks.py @@ -119,9 +119,9 @@ def _sync_user_grade_with_canvas(grade_id): ) return - due_date = parse_datetime( - existing_assignments_map[str(grade_instance.usage_key)]["due_at"] - ) + due_date = existing_assignments_map[str(grade_instance.usage_key)]["due_at"] + if due_date: + due_date = parse_datetime(due_date) if due_date and due_date < datetime.now(tz=UTC): TASK_LOG.warning( "The assignment %s is past its due date. Skipping grade sync.", diff --git a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py index d9e56d9f2..8f0d1934b 100644 --- a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py +++ b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from unittest.mock import ANY, MagicMock, patch import ddt @@ -277,6 +278,6 @@ def test_sync_canvas_due_date_extensions(self): ANY, ANY, User.objects.get(email=f"user{student_id}@abc.xyz"), - "2026-06-02T00:00:00Z", + parse_datetime("2026-06-02T00:00:00Z"), reason="Synced from canvas course: 11", ) From c4845cd889e8cff05636636fab182653f3e3d36e Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Mon, 8 Jun 2026 20:45:45 +0530 Subject: [PATCH 5/6] fixup!: rename method and improve course key handling in Canvas integration - Renamed `get_use_canvas_due_dates` to `is_canvas_dates_sync_enabled` for clarity. - Improved `sync_canvas_due_dates` management command to validate course keys and handle invalid keys gracefully. - Replaced manual user creation with `UserFactory` in `test_sync_canvas_due_date_extensions`. - Updated `create_assignment_payload` logic to avoid unnecessary nesting when setting due dates. - Adjusted `run_edx_integration_tests.sh` to generalize CMS test execution comments. --- run_edx_integration_tests.sh | 2 +- .../ol_openedx_canvas_integration/api.py | 4 +-- .../ol_openedx_canvas_integration/client.py | 26 +++++++++---------- .../cms_tasks.py | 6 ++--- .../commands/sync_canvas_due_dates.py | 26 ++++++++++++++++--- .../ol_openedx_canvas_integration/utils.py | 8 ++---- .../tests/test_cms_tasks.py | 4 +-- 7 files changed, 45 insertions(+), 31 deletions(-) diff --git a/run_edx_integration_tests.sh b/run_edx_integration_tests.sh index bd9b10418..6f535b098 100755 --- a/run_edx_integration_tests.sh +++ b/run_edx_integration_tests.sh @@ -192,7 +192,7 @@ run_plugin_tests() { exit $PYTEST_SUCCESS fi - # Run the pytest command with CMS settings (for ol_openedx_chat) + # Run the pytest command with CMS settings (for plugins that have CMS tests) 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 diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py index 270625f45..a858fd43c 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/api.py @@ -19,7 +19,7 @@ from ol_openedx_canvas_integration.constants import COURSE_KEY_ID_EMPTY from ol_openedx_canvas_integration.utils import ( get_canvas_course_id, - get_use_canvas_due_dates, + is_canvas_dates_sync_enabled, ) log = logging.getLogger(__name__) @@ -230,7 +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) + use_canvas_due_dates = is_canvas_dates_sync_enabled(course) client = CanvasClient(canvas_course_id=canvas_course_id) existing_assignment_dict = client.get_canvas_assignments() diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py index cb8d66fa4..0f4439fef 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py @@ -281,19 +281,19 @@ def create_assignment_payload(subsection_block, use_canvas_dates=False): # noqa 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 = {} + due_date_dict = {} + # If we're not using canvas dates, set the due dates from Open edX + if not use_canvas_dates: + 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 the user should enter a UTC datetime for the due + # date. Coerce this to UTC before creating the string representation. + else subsection_block.fields["due"].astimezone(pytz.UTC).isoformat() + ), + } return { "assignment": { "name": subsection_block.display_name, diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py index 2e3e8acbd..58796df55 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py @@ -30,7 +30,7 @@ from ol_openedx_canvas_integration.client import CanvasClient, create_assignment_payload from ol_openedx_canvas_integration.utils import ( get_canvas_course_id, - get_use_canvas_due_dates, + is_canvas_dates_sync_enabled, ) logger = logging.getLogger(__name__) @@ -172,7 +172,7 @@ def sync_course_assignments_with_canvas(course_id): course_key = CourseLocator.from_string(course_id) course = get_course_by_id(course_key) canvas_course_id = get_canvas_course_id(course) - use_canvas_due_dates = get_use_canvas_due_dates(course) + use_canvas_due_dates = is_canvas_dates_sync_enabled(course) if not canvas_course_id: logger.info( @@ -272,7 +272,7 @@ def _sync_canvas_due_dates(course_id: str): course_id, ) return - use_canvas_due_dates = get_use_canvas_due_dates(course) + use_canvas_due_dates = is_canvas_dates_sync_enabled(course) if not use_canvas_due_dates: TASK_LOG.info( "Due Date Sync: Disabled. Skipped for course %s", diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py index 249620515..31202e47a 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/management/commands/sync_canvas_due_dates.py @@ -1,11 +1,20 @@ """ Script for syncing Canvas due dates with Open edX + +Usage: + python manage.py cms sync_canvas_due_dates ... + python manage.py cms sync_canvas_due_dates --all + + # With Tutor (replace dev with local or k8s as appropriate) + tutor dev exec cms -- python manage.py cms sync_canvas_due_dates [options] """ import logging from pathlib import Path from django.core.management.base import BaseCommand +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from ol_openedx_canvas_integration.cms_tasks import sync_canvas_due_dates @@ -34,7 +43,15 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): # noqa: ARG002 - course_keys = options.get("course_keys", []) + course_keys = [] + for course_key in options.get("course_keys", []): + try: + course_keys.append(CourseKey.from_string(course_key.strip())) + except InvalidKeyError: + self.stderr.write( + self.style.ERROR(f"Invalid course key: {course_key}"), + ) + return all_courses = options.get("all") if not course_keys and not all_courses: @@ -42,10 +59,10 @@ def handle(self, *args, **options): # noqa: ARG002 command_name = Path(__file__).stem self.stderr.write( self.style.ERROR( - "Error: You must specify either course keys or use the --all flag.\n" # noqa: E501 + "Error: You must specify course keys or use the --all flag.\n" "Examples:\n" - f" python manage.py {command_name} --all\n" - f" python manage.py {command_name} course-v1:MITx+6.00x+2T2024" + f" python manage.py cms {command_name} --all\n" + f" python manage.py cms {command_name} course-v1:MITx+6.00x+2T2024" ) ) return @@ -65,6 +82,7 @@ def handle(self, *args, **options): # noqa: ARG002 .order_by("created") .values_list("id", flat=True) ) + if course_keys: courses = courses.filter(id__in=course_keys) self.stdout.write( diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py index 67deb87df..312cd296a 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/utils.py @@ -6,13 +6,9 @@ def get_canvas_course_id(course=None): return course.other_course_settings.get("canvas_id") if course else None -def get_use_canvas_due_dates(course=None): +def is_canvas_dates_sync_enabled(course=None): """Get the canvas due dates setting from the course settings""" - return ( - course.other_course_settings.get("use_canvas_due_dates", False) - if course - else None - ) + return course and course.other_course_settings.get("use_canvas_due_dates", False) def get_task_output_formatted_message(task_output): diff --git a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py index 8f0d1934b..4ec9ecc28 100644 --- a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py +++ b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py @@ -1,10 +1,10 @@ from __future__ import annotations -from datetime import datetime from unittest.mock import ANY, MagicMock, patch import ddt import pytest +from common.djangoapps.student.tests.factories import UserFactory from django.contrib.auth.models import User from django.test import override_settings from django.utils.dateparse import parse_datetime @@ -216,7 +216,7 @@ def test_sync_canvas_due_dates_updates_due_dates(self): def test_sync_canvas_due_date_extensions(self): for uid in [1, 4, 9, 11, 14, 37]: - User.objects.create_user(f"user{uid}", f"user{uid}@abc.xyz", "password") + UserFactory.create(username=f"user{uid}", email=f"user{uid}@abc.xyz") course, sequentials = self.create_course( {"canvas_id": 11, "use_canvas_due_dates": True} ) From 09618d82409875d0a6a9a0a222f380aed455ee16 Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Wed, 10 Jun 2026 13:48:02 +0530 Subject: [PATCH 6/6] feat: add automatic scheduling for Canvas due date sync - Introduced automatic scheduling of the `sync_canvas_due_dates` task using Celery beat. - Added CMS and LMS-specific plugin settings for production and common configurations. - Created a new task, `sync_canvas_due_dates_for_all_courses`, to sync Canvas due dates for all courses. - Updated `client.py` to include configurable query parameter overrides for assignment dates. - Refactored tests to improve readability and adhere to consistent formatting. --- src/ol_openedx_canvas_integration/README.rst | 4 ++-- .../ol_openedx_canvas_integration/app.py | 12 +++++++---- .../ol_openedx_canvas_integration/client.py | 2 +- .../cms_tasks.py | 19 +++++++++++++++++ .../settings/cms/__init__.py | 0 .../settings/cms/common.py | 16 ++++++++++++++ .../settings/cms/production.py | 21 +++++++++++++++++++ .../settings/lms/__init__.py | 0 .../settings/{ => lms}/common.py | 0 .../settings/{ => lms}/production.py | 0 .../tests/test_cms_tasks.py | 10 +++++++-- uv.lock | 2 +- 12 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/__init__.py create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/common.py create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/production.py create mode 100644 src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/__init__.py rename src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/{ => lms}/common.py (100%) rename src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/{ => lms}/production.py (100%) diff --git a/src/ol_openedx_canvas_integration/README.rst b/src/ol_openedx_canvas_integration/README.rst index 4536e817d..89fa2def0 100644 --- a/src/ol_openedx_canvas_integration/README.rst +++ b/src/ol_openedx_canvas_integration/README.rst @@ -129,13 +129,13 @@ 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). +This task is automatically scheduled to run every hour by Celery beat. If Celery beat isn't configured you can also accomplish this via cron. The management command can be run (in the dev environment) using the following Tutor command: diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/app.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/app.py index 712dab26b..d6f99ec81 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/app.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/app.py @@ -32,15 +32,19 @@ class CanvasIntegrationConfig(AppConfig): PluginSettings.CONFIG: { ProjectType.LMS: { SettingsType.PRODUCTION: { - PluginSettings.RELATIVE_PATH: "settings.production" + PluginSettings.RELATIVE_PATH: "settings.lms.production" + }, + SettingsType.COMMON: { + PluginSettings.RELATIVE_PATH: "settings.lms.common" }, - SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"}, }, ProjectType.CMS: { SettingsType.PRODUCTION: { - PluginSettings.RELATIVE_PATH: "settings.production" + PluginSettings.RELATIVE_PATH: "settings.cms.production" + }, + SettingsType.COMMON: { + PluginSettings.RELATIVE_PATH: "settings.cms.common" }, - SettingsType.COMMON: {PluginSettings.RELATIVE_PATH: "settings.common"}, }, }, PluginContexts.CONFIG: { diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py index 0f4439fef..8afd953f1 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/client.py @@ -157,7 +157,7 @@ def get_canvas_assignments(self): integration_id set. """ # This query param makes canvas return all overrides for each assignment - query = {"include[]": ["overrides"]} + query = {"include[]": ["overrides"], "override_assignment_dates": False} assignments = self.list_canvas_assignments(params=query) assignments_dict = { assignment.get("integration_id"): { diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py index 58796df55..db0e86252 100644 --- a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/cms_tasks.py @@ -22,6 +22,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import CourseLocator +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError @@ -202,6 +203,24 @@ def sync_course_assignments_with_canvas(course_id): delete_assignments(canvas, operations_map["delete"]) +@shared_task +def sync_canvas_due_dates_for_all_courses(): + """ + Synchronize due dates from Canvas for all courses in the system. + + This task retrieves a list of all course IDs and triggers an asynchronous + task to synchronize Canvas due dates for each course. + + Returns: + None + """ + courses = ( + CourseOverview.objects.all().order_by("created").values_list("id", flat=True) + ) + for course_id in courses: + sync_canvas_due_dates.delay(str(course_id)) + + @shared_task def sync_canvas_due_dates(course_id: str): """ diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/__init__.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/common.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/common.py new file mode 100644 index 000000000..cec533c11 --- /dev/null +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/common.py @@ -0,0 +1,16 @@ +"""Common settings unique to the canvas integration plugin.""" + +from celery.schedules import crontab + + +def plugin_settings(settings): + """Canvas integration plugin settings for CMS.""" + settings.CANVAS_ACCESS_TOKEN = None + settings.CANVAS_BASE_URL = None + + if not hasattr(settings, "CELERYBEAT_SCHEDULE"): + settings.CELERYBEAT_SCHEDULE = {} + settings.CELERYBEAT_SCHEDULE["sync_canvas_due_dates"] = { + "task": "ol_openedx_canvas_integration.cms_tasks.sync_canvas_due_dates_for_all_courses", # noqa: E501 + "schedule": crontab(minute=0), + } diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/production.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/production.py new file mode 100644 index 000000000..c9bef3f07 --- /dev/null +++ b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/cms/production.py @@ -0,0 +1,21 @@ +"""Production settings unique to canvas integration plugin.""" + +from celery.schedules import crontab + + +def plugin_settings(settings): + """Canvas integration plugin settings for CMS.""" + settings.CANVAS_ACCESS_TOKEN = settings.AUTH_TOKENS.get( + "CANVAS_ACCESS_TOKEN", settings.CANVAS_ACCESS_TOKEN + ) + settings.CANVAS_BASE_URL = settings.ENV_TOKENS.get( + "CANVAS_BASE_URL", settings.CANVAS_BASE_URL + ) + + if not hasattr(settings, "CELERYBEAT_SCHEDULE"): + settings.CELERYBEAT_SCHEDULE = {} + + settings.CELERYBEAT_SCHEDULE["sync_canvas_due_dates"] = { + "task": "ol_openedx_canvas_integration.cms_tasks.sync_canvas_due_dates_for_all_courses", # noqa: E501 + "schedule": crontab(minute=0), + } diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/__init__.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/common.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/common.py similarity index 100% rename from src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/common.py rename to src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/common.py diff --git a/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/production.py b/src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/production.py similarity index 100% rename from src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/production.py rename to src/ol_openedx_canvas_integration/ol_openedx_canvas_integration/settings/lms/production.py diff --git a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py index 4ec9ecc28..a3d5366e5 100644 --- a/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py +++ b/src/ol_openedx_canvas_integration/tests/test_cms_tasks.py @@ -189,7 +189,10 @@ def test_sync_canvas_due_dates_due_dates_disabled(self, other_course_settings): def test_sync_canvas_due_dates_updates_due_dates(self): course, sequentials = self.create_course( - {"canvas_id": 11, "use_canvas_due_dates": True} + { + "canvas_id": 11, + "use_canvas_due_dates": True, + } ) mock_canvas_assignments = { @@ -218,7 +221,10 @@ def test_sync_canvas_due_date_extensions(self): for uid in [1, 4, 9, 11, 14, 37]: UserFactory.create(username=f"user{uid}", email=f"user{uid}@abc.xyz") course, sequentials = self.create_course( - {"canvas_id": 11, "use_canvas_due_dates": True} + { + "canvas_id": 11, + "use_canvas_due_dates": True, + } ) sequential_0_student_ids = [11, 37, 4] diff --git a/uv.lock b/uv.lock index dbd54addb..5c2d91f76 100644 --- a/uv.lock +++ b/uv.lock @@ -2085,7 +2085,7 @@ requires-dist = [ [[package]] name = "ol-openedx-auto-select-language" -version = "0.1.0" +version = "0.1.1" source = { editable = "src/ol_openedx_auto_select_language" } dependencies = [ { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },