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 @@
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
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
Member

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

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.

on_commit starts this task right after the publish commits. The block-structure cache is rebuilt by update_course_in_cache_v2, which is queued with a default 30s countdown. BlockStructureManager.get_collected() reads from the store and checks only transformer versions, not the course version. So get_course_blocks inside this task will usually return the previous version of the course.
Could the task run after the block structure has been updated?

44 changes: 44 additions & 0 deletions openedx/core/djangoapps/course_date_signals/tasks.py
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()

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.

get_course_blocks(staff_user) resolves dates through get_dates_for_course(course_key, staff_user), so this arbitrary staff user's schedule and UserDate overrides end up written into the course-wide ContentDate rows. In self-paced courses this replaces the relative-date policy that extract_dates stores with fixed dates based on that staff user's enrollment, which breaks Personalized Learner Schedules for every learner. Could this task leave the date policy alone and only fill in assignment_title, subsection_name and block_type?

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)

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.

This task runs on the CMS worker, but get_course_assignments needs LMS URLs. course_published fires in Studio, so .delay() sends this task to the CMS queue. get_course_assignments calls reverse('jump_to', ...) for released subsections. jump_to is only defined in lms/urls.py, and in a CMS worker shell reverse('jump_to', ...) raises NoReverseMatch. So this task will fail, retry 3 times, and give up for any course with released graded content.
The block-structure tasks have the same need and are routed to the LMS worker through ALTERNATE_ENV_TASKS. I think this task needs the same routing.

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.
255 changes: 255 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,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):

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=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
)
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)
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory

from . import utils
from .. import utils


class SelfPacedDueDatesTests(ModuleStoreTestCase): # pylint: disable=missing-class-docstring
Expand Down
28 changes: 28 additions & 0 deletions openedx/core/djangoapps/course_date_signals/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import timedelta

from django.conf import settings
from edx_when.api import Assignment

from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.catalog.utils import get_course_run_details
Expand All @@ -23,6 +24,33 @@ def _catalog_integration_enabled():
return catalog_integration.is_enabled()


def to_edx_when_assignments(assignments):
"""
Convert ``get_course_assignments`` output into ``edx_when.api.Assignment`` instances.

Only subsection-level (``sequential``) assignments are kept. ``get_course_assignments``
also returns one entry per ORA step, all sharing the ORA block's key with different
due dates; since edx-when upserts on ``(course, location, 'due')`` they would collapse
into a single row that overrides the ORA's own ``due`` field.

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
if assignment.block_key.block_type == 'sequential'
]
Comment thread
kyrylo-kh marked this conversation as resolved.


def get_expected_duration(course_id):
"""
Return a `datetime.timedelta` defining the expected length of the supplied course.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ def test_successful_retire_with_userfile(setup_retirement_states): # pylint: di
@pytest.mark.parametrize('email_header', ['email', 'user_email'])
@pytest.mark.parametrize('username_header', ['username', '\ufeffusername'])
@skip_unless_lms
def test_successful_retire_with_userfile_header( # pylint: disable=redefined-outer-name, unused-argument # noqa: F811
setup_retirement_states, email_header, username_header
def test_successful_retire_with_userfile_header( # pylint: disable=redefined-outer-name, unused-argument
setup_retirement_states, email_header, username_header # noqa: F811
):
user = UserFactory.create(username='header-user', email="header-user@example.com")
username = user.username
Expand Down
Loading
Loading