-
Notifications
You must be signed in to change notification settings - Fork 4.3k
feat: [FC-7879] add signal handler to save assignment dates to edx-when models #37988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please add docstring expanding context to the test class and/or the module? |
||
| """ | ||
| 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()] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As @pkulkark mentions correctly actual return value of the |
||
|
|
||
| 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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this receiver interact with the existing one?
https://github.com/raccoongang/edx-platform/blob/b8d7a3ef8ae2f8b25038493104e5af572a7098c8/openedx/core/djangoapps/course_date_signals/handlers.py#L166
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both are connected to
SignalHandler.course_published. They run in registration order:extract_datesfirst (sync, updates block dates in edx_when), thenupdate_assignment_dates(only schedules a Celery task with on_commit). They don’t call each other; the new one defers work so the task runs after the publish (and extract_dates) have committed.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kyrylo-kh Can you please extend docstring with the description on how this signal listener is different from and extends the above mentioned
set_dates_for_course?