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