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