From 1f8b4e50c66c11d6bf633fba141782ec21c0f376 Mon Sep 17 00:00:00 2001 From: kingoftech-v01 Date: Fri, 10 Apr 2026 05:25:42 +0000 Subject: [PATCH] fix(ccx): generate CCX course outlines in LMS process CCX course_published signals are dispatched in the LMS process but the Studio-side handler that writes CourseOutlineData (cms.djangoapps.contentstore.signals.handlers.listen_for_course_publish) runs only in the CMS process, so CCX courses never had outlines and the learning_sequences API raised CourseOutlineData.DoesNotExist when the LMS Outline view tried to render them. Per ADR 0011 we cannot move outline generation into the LMS because it would require modulestore access. Instead, derive the CCX outline from the parent course's already-published CourseOutlineData, which lives in learning_sequences and is safe to read from LMS. The new replace_course_outline_for_ccx helper clones the parent outline into the CCX namespace using map_into_course, and a new LMS Celery task update_ccx_course_outline is wired to SignalHandler.course_published for CCXLocator keys. CCX overrides (start, due, visible_to_staff_only) are not baked into CourseOutlineData so cloning preserves the contract. Closes #37365 --- lms/djangoapps/ccx/tasks.py | 54 ++++++++++++- .../learning_sequences/api/__init__.py | 1 + .../learning_sequences/api/outlines.py | 55 ++++++++++++++ .../api/tests/test_outlines.py | 75 +++++++++++++++++++ 4 files changed, 183 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/ccx/tasks.py b/lms/djangoapps/ccx/tasks.py index 4328e561d47b..d1dcd3f82144 100644 --- a/lms/djangoapps/ccx/tasks.py +++ b/lms/djangoapps/ccx/tasks.py @@ -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): diff --git a/openedx/core/djangoapps/content/learning_sequences/api/__init__.py b/openedx/core/djangoapps/content/learning_sequences/api/__init__.py index e50ae2c3aca5..2c5c03b32722 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/__init__.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/__init__.py @@ -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 ) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index 297726e42bf0..3eee71b10ac1 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -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 @@ -64,6 +65,7 @@ 'get_user_course_outline_details', 'key_supports_outlines', 'replace_course_outline', + 'replace_course_outline_for_ccx', ] @@ -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. diff --git a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py index da0a2ea4c2cf..d72ba5c53b42 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/tests/test_outlines.py @@ -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 @@ -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) +