From 86490b097c81f915ff270ad6b20350d000e1022b Mon Sep 17 00:00:00 2001 From: Andrii Date: Thu, 26 Jun 2025 13:44:51 +0300 Subject: [PATCH 1/3] feat: [AXM-2300] add signal handler to save assignment dates to edx-when's ContentDate model --- .../course_date_signals/handlers.py | 13 ++ .../djangoapps/course_date_signals/tasks.py | 60 +++++ .../course_date_signals/tests/__init__.py | 0 .../course_date_signals/tests/test_tasks.py | 205 ++++++++++++++++++ 4 files changed, 278 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 6f3f4ed9a713..73ae933a7968 100644 --- a/openedx/core/djangoapps/course_date_signals/handlers.py +++ b/openedx/core/djangoapps/course_date_signals/handlers.py @@ -4,6 +4,7 @@ from datetime import timedelta import logging +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..dc309e76bacc --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/tasks.py @@ -0,0 +1,60 @@ +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 models as when_models +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) + for assignment in assignments: + LOGGER.info( + "Updating assignment '%s' with due date '%s' for course %s", + assignment.title, + assignment.date, + course_key_str + ) + if not all((assignment.date, assignment.title)): + LOGGER.warning( + "Skipping assignment '%s' for course %s because it has no date or title", + assignment, + course_key_str + ) + continue + when_models.ContentDate.objects.update_or_create( + course_id=course_key, + location=assignment.block_key, + field='due', + block_type=assignment.assignment_type, + defaults={ + 'policy': when_models.DatePolicy.objects.get_or_create(abs_date=assignment.date)[0], + 'assignment_title': assignment.title, + 'course_name': course_key.course, + 'subsection_name': assignment.title + } + ) + 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 0eac5ee92fb2290cb27a88a1639451a3bfa9deba Mon Sep 17 00:00:00 2001 From: Andrii Date: Thu, 26 Jun 2025 17:42:29 +0300 Subject: [PATCH 2/3] refactor: use edx_when api to update assignments --- .../djangoapps/course_date_signals/tasks.py | 29 ++----------------- .../course_date_signals/tests/__init__.py | 0 2 files changed, 2 insertions(+), 27 deletions(-) delete mode 100644 openedx/core/djangoapps/course_date_signals/tests/__init__.py diff --git a/openedx/core/djangoapps/course_date_signals/tasks.py b/openedx/core/djangoapps/course_date_signals/tasks.py index dc309e76bacc..923bcc7fffa0 100644 --- a/openedx/core/djangoapps/course_date_signals/tasks.py +++ b/openedx/core/djangoapps/course_date_signals/tasks.py @@ -2,7 +2,7 @@ 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 models as when_models +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 @@ -28,32 +28,7 @@ def update_assignment_dates_for_course(course_key_str): 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) - for assignment in assignments: - LOGGER.info( - "Updating assignment '%s' with due date '%s' for course %s", - assignment.title, - assignment.date, - course_key_str - ) - if not all((assignment.date, assignment.title)): - LOGGER.warning( - "Skipping assignment '%s' for course %s because it has no date or title", - assignment, - course_key_str - ) - continue - when_models.ContentDate.objects.update_or_create( - course_id=course_key, - location=assignment.block_key, - field='due', - block_type=assignment.assignment_type, - defaults={ - 'policy': when_models.DatePolicy.objects.get_or_create(abs_date=assignment.date)[0], - 'assignment_title': assignment.title, - 'course_name': course_key.course, - 'subsection_name': assignment.title - } - ) + 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) diff --git a/openedx/core/djangoapps/course_date_signals/tests/__init__.py b/openedx/core/djangoapps/course_date_signals/tests/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 From 77709c886dc77bfc672f387eeb1686a1c16ded63 Mon Sep 17 00:00:00 2001 From: Andrii Date: Tue, 1 Jul 2025 17:53:20 +0300 Subject: [PATCH 3/3] fix: add __init__.py for tests package --- openedx/core/djangoapps/course_date_signals/tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 openedx/core/djangoapps/course_date_signals/tests/__init__.py 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