From cf0938d1f0edd074d0aa84834eb8b0b331d2d718 Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Mon, 9 Feb 2026 15:29:55 +0200 Subject: [PATCH 1/4] feat: [FC-7879] add signal handler to save assignment dates to edx-when models --- .../course_date_signals/handlers.py | 13 ++ .../djangoapps/course_date_signals/tasks.py | 35 +++ .../course_date_signals/tests/__init__.py | 0 .../course_date_signals/tests/test_tasks.py | 205 ++++++++++++++++++ 4 files changed, 253 insertions(+) create mode 100644 openedx/core/djangoapps/course_date_signals/tasks.py create mode 100644 openedx/core/djangoapps/course_date_signals/tests/__init__.py create mode 100644 openedx/core/djangoapps/course_date_signals/tests/test_tasks.py diff --git a/openedx/core/djangoapps/course_date_signals/handlers.py b/openedx/core/djangoapps/course_date_signals/handlers.py index 62626e85aaac..918442888fc7 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -4,6 +4,7 @@ import logging from datetime import timedelta +from django.db import transaction from django.dispatch import receiver from edx_when.api import FIELDS_TO_EXTRACT, set_dates_for_course from xblock.fields import Scope @@ -181,3 +182,15 @@ def extract_dates(sender, course_key, **kwargs): # pylint: disable=unused-argum set_dates_for_course(course_key, date_items) except Exception: # pylint: disable=broad-except log.exception('Unable to set dates for %s on course publish', course_key) + + +@receiver(SignalHandler.course_published) +def update_assignment_dates(sender, course_key, **kwargs): # pylint: disable=unused-argument + """ + Receive the course_published signal and update assignment dates for the course. + """ + # import here, because signal is registered at startup, but items in tasks are not available yet + from .tasks import update_assignment_dates_for_course + + course_key_str = str(course_key) + transaction.on_commit(lambda: update_assignment_dates_for_course.delay(course_key_str)) diff --git a/openedx/core/djangoapps/course_date_signals/tasks.py b/openedx/core/djangoapps/course_date_signals/tasks.py new file mode 100644 index 000000000000..923bcc7fffa0 --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/tasks.py @@ -0,0 +1,35 @@ +from celery import shared_task +from celery.utils.log import get_task_logger +from django.contrib.auth import get_user_model +from edx_django_utils.monitoring import set_code_owner_attribute +from edx_when.api import update_or_create_assignments_due_dates +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.courseware.courses import get_course_assignments + + +User = get_user_model() + + +LOGGER = get_task_logger(__name__) + + +@shared_task +@set_code_owner_attribute +def update_assignment_dates_for_course(course_key_str): + """ + Celery task to update assignment dates for a course. + """ + try: + LOGGER.info("Starting to update assignment dates for course %s", course_key_str) + course_key = CourseKey.from_string(course_key_str) + staff_user = User.objects.filter(is_staff=True).first() + if not staff_user: + LOGGER.error("No staff user found to update assignment dates for course %s", course_key_str) + return + assignments = get_course_assignments(course_key, staff_user) + update_or_create_assignments_due_dates(course_key, assignments) + LOGGER.info("Successfully updated assignment dates for course %s", course_key_str) + except Exception: # pylint: disable=broad-except + LOGGER.exception("Could not update assignment dates for course %s", course_key_str) + raise diff --git a/openedx/core/djangoapps/course_date_signals/tests/__init__.py b/openedx/core/djangoapps/course_date_signals/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py new file mode 100644 index 000000000000..2e6cd9094899 --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py @@ -0,0 +1,205 @@ +from unittest.mock import Mock, patch +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from edx_when.api import models as when_models +from django.contrib.auth import get_user_model +from datetime import datetime, timezone + +from openedx.core.djangoapps.course_date_signals.tasks import update_assignment_dates_for_course + +User = get_user_model() + + +class TestUpdateAssignmentDatesForCourse(TestCase): + + def setUp(self): + self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + self.course_key_str = str(self.course_key) + self.staff_user = User.objects.create_user( + username='staff_user', + email='staff@example.com', + is_staff=True + ) + self.block_key = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test1' + ) + self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_update_assignment_dates_new_records(self, mock_get_assignments): + """ + Test inserting new records when missing. + """ + assignment = Mock() + assignment.title = 'Test Assignment' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = 'Homework' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = when_models.ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Test Assignment') + self.assertEqual(content_date.block_type, 'Homework') + self.assertEqual(content_date.policy.abs_date, self.due_date) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_update_assignment_dates_existing_records(self, mock_get_assignments): + """ + Test updating existing records when values differ. + """ + existing_policy = when_models.DatePolicy.objects.create( + abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) + ) + when_models.ContentDate.objects.create( + course_id=self.course_key, + location=self.block_key, + field='due', + block_type='Homework', + policy=existing_policy, + assignment_title='Old Title', + course_name=self.course_key.course, + subsection_name='Old Title' + ) + + assignment = Mock() + assignment.title = 'Updated Assignment' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = 'Homework' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = when_models.ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Updated Assignment') + self.assertEqual(content_date.policy.abs_date, self.due_date) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_missing_staff_user(self, mock_get_assignments): + """ + Test graceful handling when no staff user exists. + """ + User.objects.filter(is_staff=True).delete() + + update_assignment_dates_for_course(self.course_key_str) + + mock_get_assignments.assert_not_called() + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_assignment_with_null_date(self, mock_get_assignments): + """ + Test handling assignments with null dates. + """ + assignment = Mock() + assignment.title = 'No Due Date Assignment' + assignment.date = None + assignment.block_key = self.block_key + assignment.assignment_type = 'Homework' + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date_exists = when_models.ContentDate.objects.filter( + course_id=self.course_key, + location=self.block_key + ).exists() + self.assertFalse(content_date_exists) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_assignment_with_missing_metadata(self, mock_get_assignments): + """ + Test handling assignments with missing metadata. + """ + assignment = Mock() + assignment.title = None + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = None + mock_get_assignments.return_value = [assignment] + + update_assignment_dates_for_course(self.course_key_str) + + content_date_exists = when_models.ContentDate.objects.filter( + course_id=self.course_key, + location=self.block_key + ).exists() + self.assertFalse(content_date_exists) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_multiple_assignments(self, mock_get_assignments): + """ + Test processing multiple assignments. + """ + assignment1 = Mock() + assignment1.title = 'Assignment 1' + assignment1.date = self.due_date + assignment1.block_key = self.block_key + assignment1.assignment_type = 'Gradeable' + + assignment2 = Mock() + assignment2.title = 'Assignment 2' + assignment2.date = datetime(2025, 1, 15, tzinfo=timezone.utc) + assignment2.block_key = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test2' + ) + assignment2.assignment_type = 'Homework' + + mock_get_assignments.return_value = [assignment1, assignment2] + + update_assignment_dates_for_course(self.course_key_str) + + self.assertEqual(when_models.ContentDate.objects.count(), 2) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_invalid_course_key(self, mock_get_assignments): + """ + Test handling invalid course key. + """ + with self.assertRaises(Exception): + update_assignment_dates_for_course('invalid-course-key') + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_get_course_assignments_exception(self, mock_get_assignments): + """ + Test handling exception from get_course_assignments. + """ + mock_get_assignments.side_effect = Exception('API Error') + + with self.assertRaises(Exception): + update_assignment_dates_for_course(self.course_key_str) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_empty_assignments_list(self, mock_get_assignments): + """ + Test handling empty assignments list. + """ + mock_get_assignments.return_value = [] + + update_assignment_dates_for_course(self.course_key_str) + + self.assertEqual(when_models.ContentDate.objects.count(), 0) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + @patch('edx_when.models.DatePolicy.objects.get_or_create') + def test_date_policy_creation_exception(self, mock_policy_create, mock_get_assignments): + """ + Test handling exception during DatePolicy creation. + """ + assignment = Mock() + assignment.title = 'Test Assignment' + assignment.date = self.due_date + assignment.block_key = self.block_key + assignment.assignment_type = 'problem' + mock_get_assignments.return_value = [assignment] + mock_policy_create.side_effect = Exception('Database Error') + + with self.assertRaises(Exception): + update_assignment_dates_for_course(self.course_key_str) From 5197e1aa2591fa82bcd022fab2e50afafcea8e56 Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Mon, 16 Feb 2026 22:33:01 +0200 Subject: [PATCH 2/4] feat: [FC-7879] refactor according to comments --- .../course_date_signals/handlers.py | 2 +- .../djangoapps/course_date_signals/tasks.py | 32 ++--- .../course_date_signals/tests/test_tasks.py | 114 +++++++++--------- 3 files changed, 73 insertions(+), 75 deletions(-) diff --git a/openedx/core/djangoapps/course_date_signals/handlers.py b/openedx/core/djangoapps/course_date_signals/handlers.py index 918442888fc7..a0898dfff0cd 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -187,7 +187,7 @@ def extract_dates(sender, course_key, **kwargs): # pylint: disable=unused-argum @receiver(SignalHandler.course_published) def update_assignment_dates(sender, course_key, **kwargs): # pylint: disable=unused-argument """ - Receive the course_published signal and update assignment dates for the course. + Receive the course_published signal and enqueue a task to update assignment dates. """ # import here, because signal is registered at startup, but items in tasks are not available yet from .tasks import update_assignment_dates_for_course diff --git a/openedx/core/djangoapps/course_date_signals/tasks.py b/openedx/core/djangoapps/course_date_signals/tasks.py index 923bcc7fffa0..976dca7ef591 100644 --- a/openedx/core/djangoapps/course_date_signals/tasks.py +++ b/openedx/core/djangoapps/course_date_signals/tasks.py @@ -11,25 +11,27 @@ User = get_user_model() -LOGGER = get_task_logger(__name__) +log = get_task_logger(__name__) -@shared_task +@shared_task( + ignore_result=True, + autoretry_for=(Exception,), + max_retries=3, + default_retry_delay=60, +) @set_code_owner_attribute def update_assignment_dates_for_course(course_key_str): """ Celery task to update assignment dates for a course. """ - try: - LOGGER.info("Starting to update assignment dates for course %s", course_key_str) - course_key = CourseKey.from_string(course_key_str) - staff_user = User.objects.filter(is_staff=True).first() - if not staff_user: - LOGGER.error("No staff user found to update assignment dates for course %s", course_key_str) - return - assignments = get_course_assignments(course_key, staff_user) - update_or_create_assignments_due_dates(course_key, assignments) - LOGGER.info("Successfully updated assignment dates for course %s", course_key_str) - except Exception: # pylint: disable=broad-except - LOGGER.exception("Could not update assignment dates for course %s", course_key_str) - raise + course_key = CourseKey.from_string(course_key_str) + staff_user = User.objects.filter(is_staff=True).first() + if not staff_user: + raise RuntimeError( + "No staff user found to update assignment dates for course %s" % course_key_str + ) + log.info("Starting to update assignment dates for course %s", course_key_str) + assignments = get_course_assignments(course_key, staff_user) + update_or_create_assignments_due_dates(course_key, assignments) + log.info("Successfully updated assignment dates for course %s", course_key_str) diff --git a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py index 2e6cd9094899..eb2917a8d3de 100644 --- a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py +++ b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py @@ -1,9 +1,12 @@ -from unittest.mock import Mock, patch +from unittest.mock import patch +from datetime import datetime, timezone + +from django.contrib.auth import get_user_model from django.test import TestCase from opaque_keys.edx.keys import CourseKey, UsageKey -from edx_when.api import models as when_models -from django.contrib.auth import get_user_model -from datetime import datetime, timezone + +from edx_when.api import Assignment, update_or_create_assignments_due_dates +from edx_when.models import ContentDate, DatePolicy from openedx.core.djangoapps.course_date_signals.tasks import update_assignment_dates_for_course @@ -11,7 +14,7 @@ class TestUpdateAssignmentDatesForCourse(TestCase): - + def setUp(self): self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') self.course_key_str = str(self.course_key) @@ -25,21 +28,27 @@ def setUp(self): ) self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + def _assignment(self, title='Test Assignment', date=None, block_key=None, assignment_type='Homework', + subsection_name=''): + """Build an Assignment DTO as accepted by edx_when.api.update_or_create_assignments_due_dates.""" + return Assignment( + title=title, + date=date or self.due_date, + block_key=block_key or self.block_key, + assignment_type=assignment_type, + subsection_name=subsection_name, + ) + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_update_assignment_dates_new_records(self, mock_get_assignments): """ Test inserting new records when missing. """ - assignment = Mock() - assignment.title = 'Test Assignment' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = 'Homework' - mock_get_assignments.return_value = [assignment] + mock_get_assignments.return_value = [self._assignment()] update_assignment_dates_for_course(self.course_key_str) - content_date = when_models.ContentDate.objects.get( + content_date = ContentDate.objects.get( course_id=self.course_key, location=self.block_key ) @@ -52,10 +61,10 @@ def test_update_assignment_dates_existing_records(self, mock_get_assignments): """ Test updating existing records when values differ. """ - existing_policy = when_models.DatePolicy.objects.create( + existing_policy = DatePolicy.objects.create( abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) ) - when_models.ContentDate.objects.create( + ContentDate.objects.create( course_id=self.course_key, location=self.block_key, field='due', @@ -66,16 +75,13 @@ def test_update_assignment_dates_existing_records(self, mock_get_assignments): subsection_name='Old Title' ) - assignment = Mock() - assignment.title = 'Updated Assignment' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = 'Homework' - mock_get_assignments.return_value = [assignment] + mock_get_assignments.return_value = [ + self._assignment(title='Updated Assignment', subsection_name='Updated Subsection') + ] update_assignment_dates_for_course(self.course_key_str) - content_date = when_models.ContentDate.objects.get( + content_date = ContentDate.objects.get( course_id=self.course_key, location=self.block_key ) @@ -85,12 +91,14 @@ def test_update_assignment_dates_existing_records(self, mock_get_assignments): @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_missing_staff_user(self, mock_get_assignments): """ - Test graceful handling when no staff user exists. + Test that task raises when no staff user exists. """ User.objects.filter(is_staff=True).delete() - update_assignment_dates_for_course(self.course_key_str) + with self.assertRaises(RuntimeError) as ctx: + update_assignment_dates_for_course(self.course_key_str) + self.assertIn("No staff user found", str(ctx.exception)) mock_get_assignments.assert_not_called() @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') @@ -98,16 +106,13 @@ def test_assignment_with_null_date(self, mock_get_assignments): """ Test handling assignments with null dates. """ - assignment = Mock() - assignment.title = 'No Due Date Assignment' - assignment.date = None - assignment.block_key = self.block_key - assignment.assignment_type = 'Homework' - mock_get_assignments.return_value = [assignment] + mock_get_assignments.return_value = [ + self._assignment(title='No Due Date Assignment', date=None) + ] update_assignment_dates_for_course(self.course_key_str) - content_date_exists = when_models.ContentDate.objects.filter( + content_date_exists = ContentDate.objects.filter( course_id=self.course_key, location=self.block_key ).exists() @@ -116,18 +121,15 @@ def test_assignment_with_null_date(self, mock_get_assignments): @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_assignment_with_missing_metadata(self, mock_get_assignments): """ - Test handling assignments with missing metadata. + Test handling assignments with missing metadata (no date or title -> skipped by API). """ - assignment = Mock() - assignment.title = None - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = None - mock_get_assignments.return_value = [assignment] + mock_get_assignments.return_value = [ + self._assignment(title='', date=None, assignment_type='') + ] update_assignment_dates_for_course(self.course_key_str) - content_date_exists = when_models.ContentDate.objects.filter( + content_date_exists = ContentDate.objects.filter( course_id=self.course_key, location=self.block_key ).exists() @@ -138,25 +140,22 @@ def test_multiple_assignments(self, mock_get_assignments): """ Test processing multiple assignments. """ - assignment1 = Mock() - assignment1.title = 'Assignment 1' - assignment1.date = self.due_date - assignment1.block_key = self.block_key - assignment1.assignment_type = 'Gradeable' - - assignment2 = Mock() - assignment2.title = 'Assignment 2' - assignment2.date = datetime(2025, 1, 15, tzinfo=timezone.utc) - assignment2.block_key = UsageKey.from_string( + block_key2 = UsageKey.from_string( 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test2' ) - assignment2.assignment_type = 'Homework' - - mock_get_assignments.return_value = [assignment1, assignment2] + mock_get_assignments.return_value = [ + self._assignment(title='Assignment 1', assignment_type='Gradeable'), + self._assignment( + title='Assignment 2', + date=datetime(2025, 1, 15, tzinfo=timezone.utc), + block_key=block_key2, + assignment_type='Homework', + ), + ] update_assignment_dates_for_course(self.course_key_str) - self.assertEqual(when_models.ContentDate.objects.count(), 2) + self.assertEqual(ContentDate.objects.count(), 2) @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_invalid_course_key(self, mock_get_assignments): @@ -185,7 +184,7 @@ def test_empty_assignments_list(self, mock_get_assignments): update_assignment_dates_for_course(self.course_key_str) - self.assertEqual(when_models.ContentDate.objects.count(), 0) + self.assertEqual(ContentDate.objects.count(), 0) @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') @patch('edx_when.models.DatePolicy.objects.get_or_create') @@ -193,12 +192,9 @@ def test_date_policy_creation_exception(self, mock_policy_create, mock_get_assig """ Test handling exception during DatePolicy creation. """ - assignment = Mock() - assignment.title = 'Test Assignment' - assignment.date = self.due_date - assignment.block_key = self.block_key - assignment.assignment_type = 'problem' - mock_get_assignments.return_value = [assignment] + mock_get_assignments.return_value = [ + self._assignment(assignment_type='problem') + ] mock_policy_create.side_effect = Exception('Database Error') with self.assertRaises(Exception): From 02a57195c9a9196a4c12ee1e48a09add0569b96a Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Fri, 3 Jul 2026 02:28:48 +0300 Subject: [PATCH 3/4] feat: [AXM-2300] map course assignments to edx-when Assignment DTOs --- .../course_date_signals/handlers.py | 9 +- .../djangoapps/course_date_signals/tasks.py | 13 ++- .../course_date_signals/tests/test_tasks.py | 96 +++++++++++-------- .../course_date_signals/{ => tests}/tests.py | 2 +- .../djangoapps/course_date_signals/utils.py | 22 +++++ requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- uv.lock | 6 +- 8 files changed, 104 insertions(+), 48 deletions(-) rename openedx/core/djangoapps/course_date_signals/{ => tests}/tests.py (99%) diff --git a/openedx/core/djangoapps/course_date_signals/handlers.py b/openedx/core/djangoapps/course_date_signals/handlers.py index a0898dfff0cd..65255389c11a 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -187,7 +187,14 @@ def extract_dates(sender, course_key, **kwargs): # pylint: disable=unused-argum @receiver(SignalHandler.course_published) def update_assignment_dates(sender, course_key, **kwargs): # pylint: disable=unused-argument """ - Receive the course_published signal and enqueue a task to update assignment dates. + Receive the course_published signal and enqueue assignment-date syncing. + + Complements ``extract_dates`` (does not replace it). ``extract_dates`` runs + synchronously and writes each block's raw start/due/end fields into edx-when. + This receiver instead defers a Celery task (via ``transaction.on_commit``, so it + runs after publish and ``extract_dates`` commit) that resolves the course's graded + assignments through ``get_course_assignments`` and writes their due dates into + edx-when's ContentDate model - which the raw field extraction does not capture. """ # import here, because signal is registered at startup, but items in tasks are not available yet from .tasks import update_assignment_dates_for_course diff --git a/openedx/core/djangoapps/course_date_signals/tasks.py b/openedx/core/djangoapps/course_date_signals/tasks.py index 976dca7ef591..d6d6a29aed7a 100644 --- a/openedx/core/djangoapps/course_date_signals/tasks.py +++ b/openedx/core/djangoapps/course_date_signals/tasks.py @@ -1,3 +1,6 @@ +""" +Celery tasks for the course_date_signals app. +""" from celery import shared_task from celery.utils.log import get_task_logger from django.contrib.auth import get_user_model @@ -7,6 +10,7 @@ from lms.djangoapps.courseware.courses import get_course_assignments +from .utils import to_edx_when_assignments User = get_user_model() @@ -23,15 +27,18 @@ @set_code_owner_attribute def update_assignment_dates_for_course(course_key_str): """ - Celery task to update assignment dates for a course. + Sync a course's assignment due dates into edx-when. + + Resolves graded assignments via ``get_course_assignments`` (needs a staff user) + and writes them through ``update_or_create_assignments_due_dates``. """ course_key = CourseKey.from_string(course_key_str) staff_user = User.objects.filter(is_staff=True).first() if not staff_user: raise RuntimeError( - "No staff user found to update assignment dates for course %s" % course_key_str + f"No staff user found to update assignment dates for course {course_key_str}" ) log.info("Starting to update assignment dates for course %s", course_key_str) assignments = get_course_assignments(course_key, staff_user) - update_or_create_assignments_due_dates(course_key, assignments) + update_or_create_assignments_due_dates(course_key, to_edx_when_assignments(assignments)) log.info("Successfully updated assignment dates for course %s", course_key_str) diff --git a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py index eb2917a8d3de..8ad26b417806 100644 --- a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py +++ b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py @@ -1,19 +1,32 @@ +""" +Tests for the ``update_assignment_dates_for_course`` Celery task. + +The task resolves graded assignments via ``get_course_assignments`` (returning +``_Assignment`` namedtuples) and writes their due dates into edx-when. Tests use +the real namedtuple shape to exercise the ``to_edx_when_assignments`` mapping. +""" +from datetime import UTC, datetime from unittest.mock import patch -from datetime import datetime, timezone +import pytest from django.contrib.auth import get_user_model from django.test import TestCase -from opaque_keys.edx.keys import CourseKey, UsageKey - -from edx_when.api import Assignment, update_or_create_assignments_due_dates from edx_when.models import ContentDate, DatePolicy +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey +from lms.djangoapps.courseware.courses import _Assignment from openedx.core.djangoapps.course_date_signals.tasks import update_assignment_dates_for_course User = get_user_model() +_MISSING = object() + class TestUpdateAssignmentDatesForCourse(TestCase): + """ + Tests for update_assignment_dates_for_course, including the namedtuple -> edx-when mapping. + """ def setUp(self): self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') @@ -26,17 +39,23 @@ def setUp(self): self.block_key = UsageKey.from_string( 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test1' ) - self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + self.due_date = datetime(2024, 12, 31, 23, 59, 59, tzinfo=UTC) - def _assignment(self, title='Test Assignment', date=None, block_key=None, assignment_type='Homework', - subsection_name=''): - """Build an Assignment DTO as accepted by edx_when.api.update_or_create_assignments_due_dates.""" - return Assignment( - title=title, - date=date or self.due_date, + def _assignment(self, title='Test Assignment', date=_MISSING, block_key=None, assignment_type='Homework'): + """ + Build an _Assignment namedtuple exactly as get_course_assignments returns it. + """ + return _Assignment( block_key=block_key or self.block_key, + title=title, + url=None, + date=self.due_date if date is _MISSING else date, + contains_gated_content=False, + complete=False, + past_due=False, assignment_type=assignment_type, - subsection_name=subsection_name, + extra_info=None, + first_component_block_id=None, ) @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') @@ -52,9 +71,12 @@ def test_update_assignment_dates_new_records(self, mock_get_assignments): course_id=self.course_key, location=self.block_key ) - self.assertEqual(content_date.assignment_title, 'Test Assignment') - self.assertEqual(content_date.block_type, 'Homework') - self.assertEqual(content_date.policy.abs_date, self.due_date) + assert content_date.assignment_title == 'Test Assignment' + # subsection_name is mapped from the assignment title (subsection-level assignments). + assert content_date.subsection_name == 'Test Assignment' + # block_type stores the structural XBlock type, taken from the block key. + assert content_date.block_type == 'sequential' + assert content_date.policy.abs_date == self.due_date @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_update_assignment_dates_existing_records(self, mock_get_assignments): @@ -62,22 +84,20 @@ def test_update_assignment_dates_existing_records(self, mock_get_assignments): Test updating existing records when values differ. """ existing_policy = DatePolicy.objects.create( - abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) + abs_date=datetime(2024, 6, 1, tzinfo=UTC) ) ContentDate.objects.create( course_id=self.course_key, location=self.block_key, field='due', - block_type='Homework', + block_type='sequential', policy=existing_policy, assignment_title='Old Title', course_name=self.course_key.course, subsection_name='Old Title' ) - mock_get_assignments.return_value = [ - self._assignment(title='Updated Assignment', subsection_name='Updated Subsection') - ] + mock_get_assignments.return_value = [self._assignment(title='Updated Assignment')] update_assignment_dates_for_course(self.course_key_str) @@ -85,8 +105,10 @@ def test_update_assignment_dates_existing_records(self, mock_get_assignments): course_id=self.course_key, location=self.block_key ) - self.assertEqual(content_date.assignment_title, 'Updated Assignment') - self.assertEqual(content_date.policy.abs_date, self.due_date) + assert content_date.assignment_title == 'Updated Assignment' + assert content_date.policy.abs_date == self.due_date + # No duplicate row created for the same (course, location, field). + assert ContentDate.objects.filter(location=self.block_key).count() == 1 @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_missing_staff_user(self, mock_get_assignments): @@ -95,10 +117,10 @@ def test_missing_staff_user(self, mock_get_assignments): """ User.objects.filter(is_staff=True).delete() - with self.assertRaises(RuntimeError) as ctx: + with pytest.raises(RuntimeError) as ctx: update_assignment_dates_for_course(self.course_key_str) - self.assertIn("No staff user found", str(ctx.exception)) + assert "No staff user found" in str(ctx.value) mock_get_assignments.assert_not_called() @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') @@ -116,7 +138,7 @@ def test_assignment_with_null_date(self, mock_get_assignments): course_id=self.course_key, location=self.block_key ).exists() - self.assertFalse(content_date_exists) + assert not content_date_exists @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_assignment_with_missing_metadata(self, mock_get_assignments): @@ -133,7 +155,7 @@ def test_assignment_with_missing_metadata(self, mock_get_assignments): course_id=self.course_key, location=self.block_key ).exists() - self.assertFalse(content_date_exists) + assert not content_date_exists @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_multiple_assignments(self, mock_get_assignments): @@ -147,7 +169,7 @@ def test_multiple_assignments(self, mock_get_assignments): self._assignment(title='Assignment 1', assignment_type='Gradeable'), self._assignment( title='Assignment 2', - date=datetime(2025, 1, 15, tzinfo=timezone.utc), + date=datetime(2025, 1, 15, tzinfo=UTC), block_key=block_key2, assignment_type='Homework', ), @@ -155,14 +177,14 @@ def test_multiple_assignments(self, mock_get_assignments): update_assignment_dates_for_course(self.course_key_str) - self.assertEqual(ContentDate.objects.count(), 2) + assert ContentDate.objects.count() == 2 @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_invalid_course_key(self, mock_get_assignments): """ Test handling invalid course key. """ - with self.assertRaises(Exception): + with pytest.raises(InvalidKeyError): update_assignment_dates_for_course('invalid-course-key') @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') @@ -170,9 +192,9 @@ def test_get_course_assignments_exception(self, mock_get_assignments): """ Test handling exception from get_course_assignments. """ - mock_get_assignments.side_effect = Exception('API Error') + mock_get_assignments.side_effect = ValueError('API Error') - with self.assertRaises(Exception): + with pytest.raises(ValueError, match='API Error'): update_assignment_dates_for_course(self.course_key_str) @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') @@ -184,18 +206,16 @@ def test_empty_assignments_list(self, mock_get_assignments): update_assignment_dates_for_course(self.course_key_str) - self.assertEqual(ContentDate.objects.count(), 0) + assert ContentDate.objects.count() == 0 @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') - @patch('edx_when.models.DatePolicy.objects.get_or_create') + @patch('edx_when.models.DatePolicy.objects.create') def test_date_policy_creation_exception(self, mock_policy_create, mock_get_assignments): """ Test handling exception during DatePolicy creation. """ - mock_get_assignments.return_value = [ - self._assignment(assignment_type='problem') - ] - mock_policy_create.side_effect = Exception('Database Error') + mock_get_assignments.return_value = [self._assignment(assignment_type='problem')] + mock_policy_create.side_effect = ValueError('Database Error') - with self.assertRaises(Exception): + with pytest.raises(ValueError, match='Database Error'): update_assignment_dates_for_course(self.course_key_str) diff --git a/openedx/core/djangoapps/course_date_signals/tests.py b/openedx/core/djangoapps/course_date_signals/tests/tests.py similarity index 99% rename from openedx/core/djangoapps/course_date_signals/tests.py rename to openedx/core/djangoapps/course_date_signals/tests/tests.py index 938949ce2703..da5df9a6aabb 100644 --- a/openedx/core/djangoapps/course_date_signals/tests.py +++ b/openedx/core/djangoapps/course_date_signals/tests/tests.py @@ -15,7 +15,7 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory -from . import utils +from .. import utils class SelfPacedDueDatesTests(ModuleStoreTestCase): # pylint: disable=missing-class-docstring diff --git a/openedx/core/djangoapps/course_date_signals/utils.py b/openedx/core/djangoapps/course_date_signals/utils.py index 7a3db05f5dec..1ac13b09474c 100644 --- a/openedx/core/djangoapps/course_date_signals/utils.py +++ b/openedx/core/djangoapps/course_date_signals/utils.py @@ -7,6 +7,7 @@ from datetime import timedelta from django.conf import settings +from edx_when.api import Assignment from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.catalog.utils import get_course_run_details @@ -23,6 +24,27 @@ def _catalog_integration_enabled(): return catalog_integration.is_enabled() +def to_edx_when_assignments(assignments): + """ + Convert ``get_course_assignments`` output into ``edx_when.api.Assignment`` instances. + + Arguments: + assignments: iterable of ``_Assignment`` namedtuples. + + Returns: + list of ``edx_when.api.Assignment`` instances. + """ + return [ + Assignment( + title=assignment.title, + date=assignment.date, + block_key=assignment.block_key, + subsection_name=assignment.title, + ) + for assignment in assignments + ] + + def get_expected_duration(course_id): """ Return a `datetime.timedelta` defining the expected length of the supplied course. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index aafbc395d366..b5f471e36f4d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -552,7 +552,7 @@ edx-toggles==6.0.0 # openedx-platform # ora2 # xblocks-contrib -edx-when==4.0.0 +edx-when==4.1.0 # via # edx-proctoring # openedx-platform diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 9fb980ed12e9..f9658d4d4bf5 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -606,7 +606,7 @@ edx-toggles==6.0.0 # openedx-platform # ora2 # xblocks-contrib -edx-when==4.0.0 +edx-when==4.1.0 # via # edx-proctoring # openedx-platform diff --git a/uv.lock b/uv.lock index d9e58461dc59..ccccd30ede7f 100644 --- a/uv.lock +++ b/uv.lock @@ -2370,7 +2370,7 @@ wheels = [ [[package]] name = "edx-when" -version = "4.0.0" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", version = "4.2.30", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-16-openedx-platform-django42'" }, @@ -2381,9 +2381,9 @@ dependencies = [ { name = "edx-opaque-keys" }, { name = "xblock" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/61/8ff624ac9ff109f31f2a8dd27c20aa0ece6a41daeb68ac5d452592b077a5/edx_when-4.0.0.tar.gz", hash = "sha256:fbc78b688f8ce64abc46dbfbe639aa815eb5358f9a13ad3af1cbef1bb0afd537", size = 43888, upload-time = "2026-04-07T16:26:46.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/07/8520e1dd2f6f3a46716dba18d31cdb3805d54ccdb3f22f3cbcb1c87f2230/edx_when-4.1.0.tar.gz", hash = "sha256:72a7eb7ae2ceedbe688be6ca916e518270f42f3b2d5131ff259aeb5953877ddf", size = 46665, upload-time = "2026-09-02T19:29:52.645Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/f8/d43a2bf78094ca8e2698605908ccbaba78b15f8fa65a3e6efe6a18cd54f3/edx_when-4.0.0-py2.py3-none-any.whl", hash = "sha256:2ff5bed1d1942346bc5ca9cf1dd9a6f932b62756edda961578b16c94f17f8c4f", size = 35782, upload-time = "2026-04-07T16:26:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fa/e6adfe0253a9ac7ebcf4892997104fa9c2823409317e7f4007c3b9fb07bc/edx_when-4.1.0-py2.py3-none-any.whl", hash = "sha256:e40d9093233beea00b717d38ac70a974b6d7b443e8dc5847579da144387f06ee", size = 36754, upload-time = "2026-09-02T19:29:51.447Z" }, ] [[package]] From d46b1cafb890469b4cd04e7dcdd841d95f6d6dca Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Fri, 11 Sep 2026 15:02:32 +0300 Subject: [PATCH 4/4] fix: move F811 suppression onto the redefined fixture parameter test_successful_retire_with_userfile_header is the only test in this module with a multi-line signature, so ruff reports the F811 redefinition on the parameter line rather than on the `def` line, and the suppression there did not apply: test_retire_user.py:102:5: F811 Redefinition of unused `setup_retirement_states` from line 22 The pylint suppression stays on the `def` line, where pylint anchors it. --- .../course_date_signals/tests/test_tasks.py | 34 +++++++++++++++++++ .../djangoapps/course_date_signals/utils.py | 6 ++++ .../management/tests/test_retire_user.py | 4 +-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py index 8ad26b417806..225140aa4941 100644 --- a/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py +++ b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py @@ -179,6 +179,40 @@ def test_multiple_assignments(self, mock_get_assignments): assert ContentDate.objects.count() == 2 + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + def test_ora_steps_are_skipped(self, mock_get_assignments): + """ + Test that per-step ORA entries are not written to edx-when. + + get_course_assignments returns one entry per ORA step, all keyed by the ORA block + with different due dates; they must not override the ORA block's own due date. + """ + ora_block_key = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@openassessment+block@ora1' + ) + mock_get_assignments.return_value = [ + self._assignment(), + self._assignment( + title='ORA (Submission)', + date=datetime(2025, 1, 10, tzinfo=UTC), + block_key=ora_block_key, + assignment_type='Submission', + ), + self._assignment( + title='ORA (Peer Assessment)', + date=datetime(2025, 1, 20, tzinfo=UTC), + block_key=ora_block_key, + assignment_type='Peer Assessment', + ), + ] + + update_assignment_dates_for_course(self.course_key_str) + + assert not ContentDate.objects.filter(location=ora_block_key).exists() + content_date = ContentDate.objects.get(course_id=self.course_key) + assert content_date.location == self.block_key + assert content_date.policy.abs_date == self.due_date + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') def test_invalid_course_key(self, mock_get_assignments): """ diff --git a/openedx/core/djangoapps/course_date_signals/utils.py b/openedx/core/djangoapps/course_date_signals/utils.py index 1ac13b09474c..ab34e9bfd14e 100644 --- a/openedx/core/djangoapps/course_date_signals/utils.py +++ b/openedx/core/djangoapps/course_date_signals/utils.py @@ -28,6 +28,11 @@ def to_edx_when_assignments(assignments): """ Convert ``get_course_assignments`` output into ``edx_when.api.Assignment`` instances. + Only subsection-level (``sequential``) assignments are kept. ``get_course_assignments`` + also returns one entry per ORA step, all sharing the ORA block's key with different + due dates; since edx-when upserts on ``(course, location, 'due')`` they would collapse + into a single row that overrides the ORA's own ``due`` field. + Arguments: assignments: iterable of ``_Assignment`` namedtuples. @@ -42,6 +47,7 @@ def to_edx_when_assignments(assignments): subsection_name=assignment.title, ) for assignment in assignments + if assignment.block_key.block_type == 'sequential' ] diff --git a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py index 3f1b44d574b9..51430385be10 100644 --- a/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py +++ b/openedx/core/djangoapps/user_api/management/tests/test_retire_user.py @@ -98,8 +98,8 @@ def test_successful_retire_with_userfile(setup_retirement_states): # pylint: di @pytest.mark.parametrize('email_header', ['email', 'user_email']) @pytest.mark.parametrize('username_header', ['username', '\ufeffusername']) @skip_unless_lms -def test_successful_retire_with_userfile_header( # pylint: disable=redefined-outer-name, unused-argument # noqa: F811 - setup_retirement_states, email_header, username_header +def test_successful_retire_with_userfile_header( # pylint: disable=redefined-outer-name, unused-argument + setup_retirement_states, email_header, username_header # noqa: F811 ): user = UserFactory.create(username='header-user', email="header-user@example.com") username = user.username