Skip to content
Closed
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
54 changes: 52 additions & 2 deletions lms/djangoapps/ccx/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,62 @@
@receiver(SignalHandler.course_published)
def course_published_handler(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""
Consume signals that indicate course published. If course already a CCX, do nothing.
Consume signals that indicate course published.

For a master course publish, re-emit the signal for each derived CCX so that
per-CCX receivers (CourseOverview, grades, schedules, ...) run.

For a CCX publish, regenerate the CCX's outline in ``learning_sequences``
from the parent course's outline. The Studio-side handler that writes
``CourseOutlineData`` runs only in the CMS process and never sees CCX
signals dispatched from LMS, so without this path CCX courses never
acquire an outline and the LMS Outline view fails. See issue #37365 and
ADR 0011 (LMS must not touch the modulestore).
"""
if not isinstance(course_key, CCXLocator):
if isinstance(course_key, CCXLocator):
update_ccx_course_outline.delay(str(course_key))
else:
send_ccx_course_published.delay(str(course_key))


@CELERY_APP.task
@set_code_owner_attribute
def update_ccx_course_outline(ccx_course_key_str):
"""
Refresh the Learning Sequences outline for a single CCX course.

Runs in the LMS Celery worker. Uses only the public
``learning_sequences`` API and does not touch the modulestore, per
ADR 0011. See issue #37365.
"""
from openedx.core.djangoapps.content.learning_sequences.api import (
key_supports_outlines,
replace_course_outline_for_ccx,
)
from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData

try:
ccx_course_key = CCXLocator.from_string(ccx_course_key_str)
except InvalidKeyError:
log.exception("update_ccx_course_outline: invalid key %s", ccx_course_key_str)
return

if not key_supports_outlines(ccx_course_key):
return

try:
replace_course_outline_for_ccx(ccx_course_key)
except CourseOutlineData.DoesNotExist:
# Parent course has not been published through Studio yet. Log and
# bail; the next parent publish will cascade down here via
# send_ccx_course_published and retry.
log.warning(
"update_ccx_course_outline: no parent outline for %s yet; "
"will retry on next parent publish",
ccx_course_key,
)


@CELERY_APP.task
@set_code_owner_attribute
def send_ccx_course_published(course_key):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
get_user_course_outline_details, # noqa: F401
key_supports_outlines, # noqa: F401
replace_course_outline, # noqa: F401
replace_course_outline_for_ccx, # noqa: F401
)
55 changes: 55 additions & 0 deletions openedx/core/djangoapps/content/learning_sequences/api/outlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from datetime import datetime
from typing import Dict, FrozenSet, List, Optional, Union # noqa: UP035

import attr
from django.db import transaction
from django.db.models.query import QuerySet
from edx_django_utils.cache import TieredCache
Expand Down Expand Up @@ -64,6 +65,7 @@
'get_user_course_outline_details',
'key_supports_outlines',
'replace_course_outline',
'replace_course_outline_for_ccx',
]


Expand Down Expand Up @@ -415,6 +417,59 @@ def replace_course_outline(course_outline: CourseOutlineData,
_update_publish_report(course_outline, content_errors, course_context)


def replace_course_outline_for_ccx(ccx_course_key):
"""
Create or refresh the Learning Sequences outline for a CCX course by
cloning the parent course's already-published outline.

This function does not touch the modulestore and is safe to run in the
LMS process. It is the mechanism by which LMS-originated CCX
``course_published`` signals can keep ``learning_sequences`` up to date
without depending on ``cms.djangoapps.contentstore`` — see ADR 0011
("Limit modulestore use in LMS") and issue #37365.

CCX-specific overrides (``start``, ``due``, ``visible_to_staff_only``,
etc.) are not baked into ``CourseOutlineData``; they are applied at
read time by ``get_user_course_outline`` through ``edx-when`` and
``ccx.overrides``. Cloning the parent outline preserves this contract.

Raises ``CourseOutlineData.DoesNotExist`` if the parent course has no
outline yet (never been published through Studio). Callers should log
and retry rather than surface this to end users.
"""
# Local import: ccx_keys is not available in every environment that
# imports learning_sequences (e.g. some Studio-only code paths).
from ccx_keys.locator import CCXLocator

if not isinstance(ccx_course_key, CCXLocator):
raise ValueError(
f"Expected CCXLocator, got {type(ccx_course_key).__name__}"
)

parent_key = ccx_course_key.to_course_locator()
parent_outline = get_course_outline(parent_key)

def _remap_sequence(seq):
return attr.evolve(
seq,
usage_key=seq.usage_key.map_into_course(ccx_course_key),
)

def _remap_section(section):
return attr.evolve(
section,
usage_key=section.usage_key.map_into_course(ccx_course_key),
sequences=[_remap_sequence(s) for s in section.sequences],
)

ccx_outline = attr.evolve(
parent_outline,
course_key=ccx_course_key,
sections=[_remap_section(s) for s in parent_outline.sections],
)
replace_course_outline(ccx_outline)


def _update_course_context(course_outline: CourseOutlineData):
"""
Update CourseContext with given param:course_outline data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
get_user_course_outline_details,
key_supports_outlines,
replace_course_outline,
replace_course_outline_for_ccx,
)
from ..processors.enrollment_track_partition_groups import EnrollmentTrackPartitionGroupsOutlineProcessor
from .test_data import generate_sections
Expand Down Expand Up @@ -2189,3 +2190,77 @@ def test_usage_keys_removed(self, team_sets_mock, team_configuration_service_moc
assert team_partition_groups_processor.usage_keys_to_remove(self.outline) == {
self.course_key.make_usage_key('subsection', '2')
}


class ReplaceCourseOutlineForCCXTestCase(CacheIsolationTestCase):
"""
Tests for replace_course_outline_for_ccx. Regression for #37365:
CCX course outlines must be generated in the LMS process without
touching the modulestore (ADR 0011).
"""
def setUp(self):
super().setUp()
from ccx_keys.locator import CCXLocator
self.parent_key = CourseKey.from_string("course-v1:OpenedX+CCX101+2025")
self.ccx_key = CCXLocator.from_course_locator(self.parent_key, "1")
self.parent_outline = CourseOutlineData(
course_key=self.parent_key,
title="CCX101",
published_at=datetime(2025, 9, 16, tzinfo=timezone.utc),
published_version="v1",
entrance_exam_id=None,
days_early_for_beta=None,
sections=[
CourseSectionData(
usage_key=self.parent_key.make_usage_key("chapter", "ch1"),
title="Week 1",
visibility=VisibilityData(
hide_from_toc=False,
visible_to_staff_only=False,
),
sequences=[
CourseLearningSequenceData(
usage_key=self.parent_key.make_usage_key("sequential", "s1"),
title="Intro",
inaccessible_after_due=False,
visibility=VisibilityData(
hide_from_toc=False,
visible_to_staff_only=False,
),
exam=ExamData(),
),
],
),
],
self_paced=False,
course_visibility=CourseVisibility.PRIVATE,
)
replace_course_outline(self.parent_outline)

def test_unit_clones_parent_outline_into_ccx_namespace(self):
"""Unit: replace_course_outline_for_ccx clones the parent outline structure."""
replace_course_outline_for_ccx(self.ccx_key)
ccx_outline = get_course_outline(self.ccx_key)
assert ccx_outline.course_key == self.ccx_key
assert ccx_outline.title == "CCX101"
assert ccx_outline.published_version == "v1"
assert len(ccx_outline.sections) == 1
section = ccx_outline.sections[0]
assert section.usage_key.course_key == self.ccx_key
assert len(section.sequences) == 1
assert section.sequences[0].usage_key.course_key == self.ccx_key

def test_integration_raises_when_parent_not_published(self):
"""Integration: raises DoesNotExist when the parent outline is missing."""
from ccx_keys.locator import CCXLocator
other_ccx = CCXLocator.from_course_locator(
CourseKey.from_string("course-v1:OpenedX+Nope+2025"), "1",
)
with self.assertRaises(CourseOutlineData.DoesNotExist):
replace_course_outline_for_ccx(other_ccx)

def test_bug_37365_regression_rejects_non_ccx_key(self):
"""Regression for #37365: must reject non-CCX keys with ValueError."""
with self.assertRaises(ValueError):
replace_course_outline_for_ccx(self.parent_key)