Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions openedx/core/djangoapps/course_date_signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

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_dates first (sync, updates block dates in edx_when), then update_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.

Copy link
Copy Markdown
Member

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?

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))
45 changes: 45 additions & 0 deletions openedx/core/djangoapps/course_date_signals/tasks.py
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)
Empty file.
220 changes: 220 additions & 0 deletions openedx/core/djangoapps/course_date_signals/tests/test_tasks.py
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):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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()]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As @pkulkark mentions correctly actual return value of the get_course_assignments is different from the Assignment. So this mock masks the issue with subsection_name, either _Assignment named tuple should be used, or a helper with explicit mapping as suggested.


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)
22 changes: 22 additions & 0 deletions openedx/core/djangoapps/course_date_signals/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,35 @@

from datetime import timedelta

from edx_when.api import Assignment
from openedx.core.djangoapps.catalog.utils import get_course_run_details


MIN_DURATION = timedelta(weeks=4)
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.
Expand Down