Repository navigation
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
cf0938d
5197e1a
02a5719
d46b1ca
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 |
|---|---|---|
|
|
@@ -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,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)) | ||
|
Contributor
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.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| """ | ||
| 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() | ||
|
Contributor
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.
|
||
| if not staff_user: | ||
| raise RuntimeError( | ||
| 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) | ||
|
Contributor
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. This task runs on the CMS worker, but |
||
| 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,255 @@ | ||
| """ | ||
| 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 | ||
|
|
||
| import pytest | ||
| from django.contrib.auth import get_user_model | ||
| from django.test import TestCase | ||
| 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): | ||
|
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=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 | ||
| ) | ||
| 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): | ||
| """ | ||
| Test updating existing records when values differ. | ||
| """ | ||
| existing_policy = DatePolicy.objects.create( | ||
| abs_date=datetime(2024, 6, 1, tzinfo=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 | ||
| ) | ||
| 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): | ||
| """ | ||
| Test that task raises when no staff user exists. | ||
| """ | ||
| User.objects.filter(is_staff=True).delete() | ||
|
|
||
| with pytest.raises(RuntimeError) as ctx: | ||
| update_assignment_dates_for_course(self.course_key_str) | ||
|
|
||
| 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') | ||
| 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() | ||
| 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): | ||
| """ | ||
| 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() | ||
| assert not 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=UTC), | ||
| block_key=block_key2, | ||
| assignment_type='Homework', | ||
| ), | ||
| ] | ||
|
|
||
| update_assignment_dates_for_course(self.course_key_str) | ||
|
|
||
| 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): | ||
| """ | ||
| Test handling invalid course key. | ||
| """ | ||
| with pytest.raises(InvalidKeyError): | ||
| 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 = ValueError('API Error') | ||
|
|
||
| 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') | ||
| 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) | ||
|
|
||
| assert 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 = ValueError('Database Error') | ||
|
|
||
| with pytest.raises(ValueError, match='Database Error'): | ||
| 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?