diff --git a/openedx/core/djangoapps/course_date_signals/handlers.py b/openedx/core/djangoapps/course_date_signals/handlers.py index 6f3f4ed9a713..13b0f96cf69f 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,22 @@ 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 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 + + 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..24413befa014 --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/tasks.py @@ -0,0 +1,45 @@ +""" +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 +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 + +from .utils import to_edx_when_assignments + + +User = get_user_model() + + +log = get_task_logger(__name__) + + +@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): + """ + 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 + ) + 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, 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/__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..d3abb26593af --- /dev/null +++ b/openedx/core/djangoapps/course_date_signals/tests/test_tasks.py @@ -0,0 +1,220 @@ +""" +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 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.models import ContentDate, DatePolicy +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') + 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) + + 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, + extra_info=None, + first_component_block_id=None, + ) + + @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. + """ + mock_get_assignments.return_value = [self._assignment()] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = ContentDate.objects.get( + course_id=self.course_key, + location=self.block_key + ) + self.assertEqual(content_date.assignment_title, 'Test Assignment') + # subsection_name is mapped from the assignment title (subsection-level assignments). + self.assertEqual(content_date.subsection_name, 'Test Assignment') + # block_type stores the structural XBlock type, taken from the block key. + self.assertEqual(content_date.block_type, 'sequential') + 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 = DatePolicy.objects.create( + abs_date=datetime(2024, 6, 1, tzinfo=timezone.utc) + ) + ContentDate.objects.create( + course_id=self.course_key, + location=self.block_key, + field='due', + 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')] + + update_assignment_dates_for_course(self.course_key_str) + + content_date = 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) + # No duplicate row created for the same (course, location, field). + self.assertEqual(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): + """ + Test that task raises when no staff user exists. + """ + User.objects.filter(is_staff=True).delete() + + 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') + def test_assignment_with_null_date(self, mock_get_assignments): + """ + Test handling assignments with null dates. + """ + 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 = 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 (no date or title -> skipped by API). + """ + mock_get_assignments.return_value = [ + self._assignment(title='', date=None, assignment_type='') + ] + + update_assignment_dates_for_course(self.course_key_str) + + content_date_exists = 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. + """ + block_key2 = UsageKey.from_string( + 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@test2' + ) + 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(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(ContentDate.objects.count(), 0) + + @patch('openedx.core.djangoapps.course_date_signals.tasks.get_course_assignments') + @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') + + with self.assertRaises(Exception): + update_assignment_dates_for_course(self.course_key_str) diff --git a/openedx/core/djangoapps/course_date_signals/utils.py b/openedx/core/djangoapps/course_date_signals/utils.py index 78447f32610c..4034798b930f 100644 --- a/openedx/core/djangoapps/course_date_signals/utils.py +++ b/openedx/core/djangoapps/course_date_signals/utils.py @@ -6,6 +6,7 @@ from datetime import timedelta +from edx_when.api import Assignment from openedx.core.djangoapps.catalog.utils import get_course_run_details @@ -13,6 +14,27 @@ MAX_DURATION = timedelta(weeks=18) +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.