From 80da588307b561c4cccbfee136e02bf698dfc1af Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 19 Nov 2025 11:30:23 -0500 Subject: [PATCH 01/20] feat: add toggle for audit preview of verified content --- lms/djangoapps/course_home_api/toggles.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 052862796c75..0f4f410a1767 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -51,6 +51,21 @@ ) +# Waffle flag to enable audit learner preview of course structure visible to verified learners. +# +# .. toggle_name: course_home.audit_learner_verified_preview +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Where enabled, audit learners can see the presence of the sections / units +# otherwise restricted to verified learners. The content itself remains inaccessible. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2025-11-07 +# .. toggle_target_removal_date: None +COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.audit_learner_verified_preview', __name__ +) + + def course_home_mfe_progress_tab_is_active(course_key): # Avoiding a circular dependency from .models import DisableProgressPageStackedConfig @@ -73,3 +88,10 @@ def send_course_progress_analytics_for_student_is_enabled(course_key): Returns True if the course completion analytics feature is enabled for a given course. """ return COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT.is_enabled(course_key) + + +def audit_learner_verified_preview_is_enabled(course_key): + """ + Returns True if the audit learner verified preview feature is enabled for a given course. + """ + return COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW.is_enabled(course_key) From 72a6b0562cba60b0c73dabef5f17265a08bea605 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 19 Nov 2025 11:32:04 -0500 Subject: [PATCH 02/20] docs: fix incorrect docstring --- openedx/features/course_experience/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py index c20b7f077136..8cc9b854f377 100644 --- a/openedx/features/course_experience/utils.py +++ b/openedx/features/course_experience/utils.py @@ -76,11 +76,11 @@ def recurse_num_graded_problems(block): def recurse_mark_auth_denial(block): """ - Mark this block as 'scored' if any of its descendents are 'scored' (that is, 'has_score' and 'weight' > 0). + Mark this block access as denied for any reason found in its descendents. """ own_denial_reason = {block['authorization_denial_reason']} if 'authorization_denial_reason' in block else set() # Use a list comprehension to force the recursion over all children, rather than just stopping - # at the first child that is scored. + # at the first child that is blocked. child_denial_reasons = own_denial_reason.union( *(recurse_mark_auth_denial(child) for child in block.get('children', [])) ) From 67a6b1f2068e0f16ffac8e0a615a80639baf8287 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 19 Nov 2025 14:45:16 -0500 Subject: [PATCH 03/20] feat: add extended logic for when a user / course should allow audit preview of verified content This is, specifically, when all 3 of the critera are met: 1. The feature flag is enabled for the course. 2. The requesting user is enrolled as audit. 3. The course has a verified track. --- lms/djangoapps/course_home_api/toggles.py | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 0f4f410a1767..201d7c9409c8 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -3,6 +3,8 @@ """ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment WAFFLE_FLAG_NAMESPACE = 'course_home' @@ -95,3 +97,28 @@ def audit_learner_verified_preview_is_enabled(course_key): Returns True if the audit learner verified preview feature is enabled for a given course. """ return COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW.is_enabled(course_key) + +def learner_can_preview_verifeied_content(course_key, user): + """ + Determine if an audit learner can preview verified content in a course. + + Args: + course_key: The course identifier. + user: The user object + Returns: + True if the learner can preview verified content, False otherwise. + """ + + # Check if the feature is enabled for the course + feature_enabled = audit_learner_verified_preview_is_enabled(course_key) + + # Check if the course has a verified mode + course_has_verified_mode = CourseMode.has_verified_mode(course_key) + + # Get user enrollment information + enrollment = CourseEnrollment.get_enrollment(user, course_key) + user_enrolled_as_audit = enrollment is not None and enrollment.mode == CourseMode.AUDIT + + return ( + feature_enabled and user_enrolled_as_audit and course_has_verified_mode + ) From 2a1bc07ce0fb0e0a38ddddc941f3d9e0c624d32c Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 19 Nov 2025 14:59:01 -0500 Subject: [PATCH 04/20] feat: when audit preview of verified content is enabled, mark non-audit content as previewable Adds new previewable_sequences to UserCourseOutlineData --- .../content/learning_sequences/api/outlines.py | 14 ++++++++++++++ .../djangoapps/content/learning_sequences/data.py | 3 +++ 2 files changed, 17 insertions(+) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index cd2b12d03f1c..11d2f7fc8331 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -12,6 +12,7 @@ from django.db.models.query import QuerySet from edx_django_utils.cache import TieredCache from edx_django_utils.monitoring import function_trace, set_custom_attribute +from lms.djangoapps.course_home_api.toggles import learner_can_preview_verifeied_content from opaque_keys import OpaqueKey from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator @@ -318,6 +319,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes full_course_outline = get_course_outline(course_key) user_can_see_all_content = can_see_all_content(user, course_key) + user_can_preview_verified_content = learner_can_preview_verifeied_content(course_key, user) # These are processors that alter which sequences are visible to students. # For instance, certain sequences that are intentionally hidden or not yet @@ -340,6 +342,8 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes processors = {} usage_keys_to_remove = set() inaccessible_sequences = set() + preview_usage_keys = set() + for name, processor_cls in processor_classes: # Future optimization: This should be parallelizable (don't rely on a # particular ordering). @@ -349,6 +353,14 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes if not user_can_see_all_content: # function_trace lets us see how expensive each processor is being. with function_trace(f'learning_sequences.api.outline_processors.{name}'): + + # An exception is made for audit preview of verified content. + # We don't want to remove content here, instead we + # ... this will get marked later when assembling outline + if name == 'enrollment_track_partitions' and user_can_preview_verified_content: + preview_usage_keys |= processor.usage_keys_to_remove(full_course_outline) + continue + processor_usage_keys_removed = processor.usage_keys_to_remove(full_course_outline) processor_inaccessible_sequences = processor.inaccessible_sequences(full_course_outline) usage_keys_to_remove |= processor_usage_keys_removed @@ -357,12 +369,14 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes # Open question: Does it make sense to remove a Section if it has no Sequences in it? trimmed_course_outline = full_course_outline.remove(usage_keys_to_remove) accessible_sequences = frozenset(set(trimmed_course_outline.sequences) - inaccessible_sequences) + previewable_sequences = frozenset(preview_usage_keys) user_course_outline = UserCourseOutlineData( base_outline=full_course_outline, user=user, at_time=at_time, accessible_sequences=accessible_sequences, + previewable_sequences=previewable_sequences, **{ name: getattr(trimmed_course_outline, name) for name in [ diff --git a/openedx/core/djangoapps/content/learning_sequences/data.py b/openedx/core/djangoapps/content/learning_sequences/data.py index c13b451490ab..4a6e6da3a0d5 100644 --- a/openedx/core/djangoapps/content/learning_sequences/data.py +++ b/openedx/core/djangoapps/content/learning_sequences/data.py @@ -361,6 +361,9 @@ class is a pretty dumb container that doesn't understand anything about how # not be able to access anything inside. accessible_sequences: FrozenSet[UsageKey] + # Sequences that are not accessible, but are previewable by an audit learner. + previewable_sequences: FrozenSet[UsageKey] + @attr.s(frozen=True, auto_attribs=True) class UserCourseOutlineDetailsData: From e2c95befd7c08305abe82162499a61ddd35ea071 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Wed, 19 Nov 2025 14:59:57 -0500 Subject: [PATCH 05/20] feat: mark previewable sections for audit learners who can preview verified content --- .../course_home_api/outline/serializers.py | 1 + lms/djangoapps/course_home_api/outline/views.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/course_home_api/outline/serializers.py b/lms/djangoapps/course_home_api/outline/serializers.py index cfa518138a95..f66012362327 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -62,6 +62,7 @@ def get_blocks(self, block): # pylint: disable=missing-function-docstring 'type': block_type, 'has_scheduled_content': block.get('has_scheduled_content'), 'hide_from_toc': block.get('hide_from_toc'), + 'is_preview': block.get('is_preview', False), }, } if 'special_exam_info' in self.context.get('extra_fields', []) and block.get('special_exam_info'): diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 7c5307cba764..ddde78244d88 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -36,7 +36,7 @@ ) from lms.djangoapps.course_home_api.utils import get_course_or_403 from lms.djangoapps.course_home_api.tasks import collect_progress_for_user_in_course -from lms.djangoapps.course_home_api.toggles import send_course_progress_analytics_for_student_is_enabled +from lms.djangoapps.course_home_api.toggles import learner_can_preview_verifeied_content, send_course_progress_analytics_for_student_is_enabled from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section @@ -209,6 +209,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key) allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE + allow_preview_of_verified_content = learner_can_preview_verifeied_content(course_key, request.user) # User locale settings user_timezone_locale = user_timezone_locale_prefs(request) @@ -339,6 +340,19 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements ) ] if 'children' in chapter_data else [] + # For audit preview of verified content, we don't remove verified content. + # Instead, we mark it as preview so the frontend can handle it appropriately. + if allow_preview_of_verified_content: + previewable_sequences = {str(usage_key) for usage_key in user_course_outline.previewable_sequences} + + # Iterate through course_blocks to mark previewable sequences and chapters + for chapter_data in course_blocks['children']: + if chapter_data['id'] in previewable_sequences: + chapter_data['is_preview'] = True + for seq_data in chapter_data.get('children', []): + if seq_data['id'] in previewable_sequences: + seq_data['is_preview'] = True + user_has_passing_grade = False if not request.user.is_anonymous: user_grade = CourseGradeFactory().read(request.user, course) From 935a53fe233f6ba704202a39f59e626b5c3a9145 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Fri, 21 Nov 2025 14:12:57 -0500 Subject: [PATCH 06/20] style: fix typo in name --- lms/djangoapps/course_home_api/outline/views.py | 4 ++-- lms/djangoapps/course_home_api/toggles.py | 2 +- .../djangoapps/content/learning_sequences/api/outlines.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index ddde78244d88..ac6655b92387 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -36,7 +36,7 @@ ) from lms.djangoapps.course_home_api.utils import get_course_or_403 from lms.djangoapps.course_home_api.tasks import collect_progress_for_user_in_course -from lms.djangoapps.course_home_api.toggles import learner_can_preview_verifeied_content, send_course_progress_analytics_for_student_is_enabled +from lms.djangoapps.course_home_api.toggles import learner_can_preview_verified_content, send_course_progress_analytics_for_student_is_enabled from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section @@ -209,7 +209,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key) allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE - allow_preview_of_verified_content = learner_can_preview_verifeied_content(course_key, request.user) + allow_preview_of_verified_content = learner_can_preview_verified_content(course_key, request.user) # User locale settings user_timezone_locale = user_timezone_locale_prefs(request) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 201d7c9409c8..08764bbdb45b 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -98,7 +98,7 @@ def audit_learner_verified_preview_is_enabled(course_key): """ return COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW.is_enabled(course_key) -def learner_can_preview_verifeied_content(course_key, user): +def learner_can_preview_verified_content(course_key, user): """ Determine if an audit learner can preview verified content in a course. diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index 11d2f7fc8331..82ada22693ee 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -12,7 +12,7 @@ from django.db.models.query import QuerySet from edx_django_utils.cache import TieredCache from edx_django_utils.monitoring import function_trace, set_custom_attribute -from lms.djangoapps.course_home_api.toggles import learner_can_preview_verifeied_content +from lms.djangoapps.course_home_api.toggles import learner_can_preview_verified_content from opaque_keys import OpaqueKey from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator @@ -319,7 +319,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes full_course_outline = get_course_outline(course_key) user_can_see_all_content = can_see_all_content(user, course_key) - user_can_preview_verified_content = learner_can_preview_verifeied_content(course_key, user) + user_can_preview_verified_content = learner_can_preview_verified_content(course_key, user) # These are processors that alter which sequences are visible to students. # For instance, certain sequences that are intentionally hidden or not yet From 3c2141edb1636cc4d15d7da29588291dcdf0fe9d Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 14:30:15 -0500 Subject: [PATCH 07/20] refactor: more efficient verified preview check Return early for any disqualifying check instead of chekcing all values first --- lms/djangoapps/course_home_api/toggles.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 08764bbdb45b..9d7c76978ca9 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -108,17 +108,20 @@ def learner_can_preview_verified_content(course_key, user): Returns: True if the learner can preview verified content, False otherwise. """ - - # Check if the feature is enabled for the course + # To preview verified content, the feature must be enabled for the course... feature_enabled = audit_learner_verified_preview_is_enabled(course_key) + if not feature_enabled: + return False - # Check if the course has a verified mode + # ... the the course must have a verified mode course_has_verified_mode = CourseMode.has_verified_mode(course_key) + if not course_has_verified_mode: + return False - # Get user enrollment information + # ... and the user must be enrolled as audit enrollment = CourseEnrollment.get_enrollment(user, course_key) user_enrolled_as_audit = enrollment is not None and enrollment.mode == CourseMode.AUDIT + if not user_enrolled_as_audit: + return False - return ( - feature_enabled and user_enrolled_as_audit and course_has_verified_mode - ) + return True From 84d0f0c846c131a3e1110d062ecea8d611eab6c4 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 14:30:34 -0500 Subject: [PATCH 08/20] test: add tests for Course Home API toggle logic --- .../course_home_api/tests/test_toggles.py | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 lms/djangoapps/course_home_api/tests/test_toggles.py diff --git a/lms/djangoapps/course_home_api/tests/test_toggles.py b/lms/djangoapps/course_home_api/tests/test_toggles.py new file mode 100644 index 000000000000..b21b86e22e2d --- /dev/null +++ b/lms/djangoapps/course_home_api/tests/test_toggles.py @@ -0,0 +1,121 @@ +""" +Tests for Course Home API toggles. +""" +from unittest.mock import Mock, patch + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.course_modes.models import CourseMode + +from ..toggles import learner_can_preview_verified_content + + +class TestLearnerCanPreviewVerifiedContent(TestCase): + """Test cases for learner_can_preview_verified_content function.""" + + def setUp(self): + """Set up test fixtures.""" + self.course_key = CourseKey.from_string("course-v1:TestX+CS101+2024") + self.user = Mock() + + # Set up patchers + self.feature_enabled_patcher = patch( + "lms.djangoapps.course_home_api.toggles.audit_learner_verified_preview_is_enabled" + ) + self.has_verified_mode_patcher = patch( + "common.djangoapps.course_modes.models.CourseMode.has_verified_mode" + ) + self.get_enrollment_patcher = patch( + "common.djangoapps.student.models.CourseEnrollment.get_enrollment" + ) + + # Start patchers + self.mock_feature_enabled = self.feature_enabled_patcher.start() + self.mock_has_verified_mode = self.has_verified_mode_patcher.start() + self.mock_get_enrollment = self.get_enrollment_patcher.start() + + def _enroll_user(self, mode): + """Helper method to set up user enrollment mock.""" + mock_enrollment = Mock() + mock_enrollment.mode = mode + self.mock_get_enrollment.return_value = mock_enrollment + + def tearDown(self): + """Clean up patchers.""" + self.feature_enabled_patcher.stop() + self.has_verified_mode_patcher.stop() + self.get_enrollment_patcher.stop() + + def test_all_conditions_met_returns_true(self): + """Test that function returns True when all conditions are met.""" + # Given the feature is enabled, course has verified mode, and user is enrolled as audit + self.mock_feature_enabled.return_value = True + self.mock_has_verified_mode.return_value = True + self._enroll_user(CourseMode.AUDIT) + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be True + self.assertTrue(result) + + def test_feature_disabled_returns_false(self): + """Test that function returns False when feature is disabled.""" + # Given the feature is disabled + self.mock_feature_enabled.return_value = False + + # ... even if all other conditions are met + self.mock_has_verified_mode.return_value = True + self._enroll_user(CourseMode.AUDIT) + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) + + def test_no_verified_mode_returns_false(self): + """Test that function returns False when course has no verified mode.""" + # Given the course does not have a verified mode + self.mock_has_verified_mode.return_value = False + + # ... even if all other conditions are met + self.mock_feature_enabled.return_value = True + self._enroll_user(CourseMode.AUDIT) + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) + + def test_no_enrollment_returns_false(self): + """Test that function returns False when user is not enrolled.""" + # Given the user is unenrolled + self.mock_get_enrollment.return_value = None + + # ... even if all other conditions are met + self.mock_feature_enabled.return_value = True + self.mock_has_verified_mode.return_value = True + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) + + def test_verified_enrollment_returns_false(self): + """Test that function returns False when user is enrolled in verified mode.""" + # Given the user is not enrolled as audit + self._enroll_user(CourseMode.VERIFIED) + + # ... even if all other conditions are met + self.mock_feature_enabled.return_value = True + self.mock_has_verified_mode.return_value = True + + # When I check if the learner can preview verified content + result = learner_can_preview_verified_content(self.course_key, self.user) + + # Then the result should be False + self.assertFalse(result) From ca0f6c12a6ec884dc449656a20026ce7078274b7 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 18:04:47 -0500 Subject: [PATCH 09/20] test: add tests for outline view with audit preview --- .../outline/tests/test_view.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index 74e22e5fcc4b..9ad4d7caa6e4 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -13,6 +13,7 @@ from django.test import override_settings from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import UsageKey from cms.djangoapps.contentstore.outlines import update_outline_from_modulestore from common.djangoapps.course_modes.models import CourseMode @@ -43,6 +44,7 @@ BlockFactory, CourseFactory ) +from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID @ddt.ddt @@ -484,6 +486,88 @@ def test_course_progress_analytics_disabled(self, mock_task): self.client.get(self.url) mock_task.assert_not_called() + # Tests for verified content preview functionality + # These tests cover the feature that allows audit learners to preview + # the structure of verified-only content without access to the content itself + + @patch('lms.djangoapps.course_home_api.outline.views.learner_can_preview_verified_content') + def test_verified_content_preview_disabled_integration(self, mock_preview_function): + """Test that when verified preview is disabled, no preview markers are added.""" + # Given a course with some Verified only sequences + with self.store.bulk_operations(self.course.id): + chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + sequential = BlockFactory.create( + category='sequential', + parent_location=chapter.location, + display_name='Verified Sequential', + group_access={ENROLLMENT_TRACK_PARTITION_ID: [2]} # restrict to verified only + ) + update_outline_from_modulestore(self.course.id) + + # ... where the preview feature is disabled + mock_preview_function.return_value = False + + # When I access them as an audit user + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + response = self.client.get(self.url) + + # Then I get a valid response back + assert response.status_code == 200 + + # ... with course_blocks populated + course_blocks = response.data['course_blocks']["blocks"] + + # ... but with verified content omitted + assert str(sequential.location) not in course_blocks + + # ... and no block has preview set to true + for block in course_blocks: + assert course_blocks[block].get('is_preview') is not True + + @patch('lms.djangoapps.course_home_api.outline.views.learner_can_preview_verified_content') + @patch('lms.djangoapps.course_home_api.outline.views.get_user_course_outline') + def test_verified_content_preview_enabled_marks_previewable_content(self, mock_outline, mock_preview_enabled): + """Test that when verified preview is enabled, previewable sequences and chapters are marked.""" + # Given a course with some Verified only sequences and some regular sequences + with self.store.bulk_operations(self.course.id): + chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) + verified_sequential = BlockFactory.create( + category='sequential', + parent_location=chapter.location, + display_name='Verified Sequential', + ) + regular_sequential = BlockFactory.create( + category='sequential', + parent_location=chapter.location, + display_name='Regular Sequential' + ) + update_outline_from_modulestore(self.course.id) + + # ... with an outline that correctly identifies previewable sequences + mock_course_outline = Mock() + mock_course_outline.sections = {Mock(usage_key=chapter.location)} + mock_course_outline.sequences = {verified_sequential.location, regular_sequential.location} + mock_course_outline.previewable_sequences = {verified_sequential.location} + mock_outline.return_value = mock_course_outline + + # When I access them as an audit user with preview enabled + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + mock_preview_enabled.return_value = True + + # Then I get a valid response back + response = self.client.get(self.url) + assert response.status_code == 200 + + # ... with course_blocks populated + course_blocks = response.data['course_blocks']["blocks"] + + for block in course_blocks: + # ... and the verified only content is marked as preview only + if UsageKey.from_string(block) in mock_course_outline.previewable_sequences: + assert course_blocks[block].get('is_preview') is True + # ... and the regular content is not marked as preview + else: + assert course_blocks[block].get('is_preview') is False @ddt.ddt class SidebarBlocksTestViews(BaseCourseHomeTests): From ac5904803b52197b93beb4aa02aab0594fa4dd8c Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 19:03:10 -0500 Subject: [PATCH 10/20] test: add tests for outline processing with verified preveiw --- .../api/tests/test_outlines.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) 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 20effa6b16cd..2315ef1e5740 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 @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.db.models import signals +from common.djangoapps.course_modes.tests.factories import CourseModeFactory from edx_proctoring.exceptions import ProctoredExamNotFoundException from edx_toggles.toggles.testutils import override_waffle_flag from edx_when.api import set_dates_for_course @@ -167,6 +168,8 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase): @classmethod def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1") + CourseModeFactory.create(course_id=course_key, mode_slug='verified') + # Users... cls.global_staff = UserFactory.create( username='global_staff', email='gstaff@example.com', is_staff=True @@ -176,6 +179,9 @@ def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called ) cls.beta_tester = BetaTesterFactory(course_key=course_key) cls.anonymous_user = AnonymousUser() + cls.verified_student = UserFactory.create( + username='verified', email='verified@example.com', is_staff=False + ) # Seed with data cls.course_key = course_key @@ -196,6 +202,8 @@ def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") # Enroll beta tester in the course cls.beta_tester.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") + # Enroll verified student in the course as verified + cls.verified_student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode=CourseMode.VERIFIED) def test_simple_outline(self): """This outline is the same for everyone.""" @@ -228,6 +236,94 @@ def test_simple_outline(self): ) assert global_staff_outline_details.outline == global_staff_outline + @patch('openedx.core.djangoapps.content.learning_sequences.api.outlines.learner_can_preview_verified_content') + def test_audit_preview_of_verified_content_enabled(self, mock_learner_can_preview_verified_content): + # Given an outline where some content is restricted to verified only + audit_outline = self.simple_outline + verified_sequence = attr.evolve( + audit_outline.sections[0].sequences[0], + user_partition_groups = { + ENROLLMENT_TRACK_PARTITION_ID: [2] # restrict to verified only + } + ) + audit_outline.sections[0].sequences[0] = verified_sequence + replace_course_outline(audit_outline) + at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) + + # ... and where the audit learner verified preview feature is enabled + mock_learner_can_preview_verified_content.return_value = True + + # When I access them as an audit user + audit_student_outline = get_user_course_outline( + self.course_key, self.student, at_time + ) + + # Then verified-only content is marked as previewable for the audit user + assert verified_sequence.usage_key in audit_student_outline.previewable_sequences + + # When I access them as a verified user, which would disable this preview check + mock_learner_can_preview_verified_content.return_value = False + verified_student_outline = get_user_course_outline( + self.course_key, self.verified_student, at_time + ) + + global_staff_outline = get_user_course_outline( + self.course_key, self.global_staff, at_time + ) + + # For verified and staff, the outline is unchanged + assert verified_student_outline.sections == global_staff_outline.sections + + # ... and do not contain any previewable sequences + assert verified_student_outline.previewable_sequences == set() + assert global_staff_outline.previewable_sequences == set() + + + @patch('openedx.core.djangoapps.content.learning_sequences.api.outlines.learner_can_preview_verified_content') + def test_audit_preview_of_verified_content_disabled(self, mock_learner_can_preview_verified_content): + """ + This outline has verified content that an audit user can preview + only when the feature is enabled. + """ + # Given an outline where some content is restricted to verified only + audit_outline = self.simple_outline + verified_sequence = attr.evolve( + audit_outline.sections[0].sequences[0], + user_partition_groups = { + ENROLLMENT_TRACK_PARTITION_ID: [2] # restrict to verified only + } + ) + audit_outline.sections[0].sequences[0] = verified_sequence + replace_course_outline(audit_outline) + at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) + + # ... and where the audit learner verified preview feature is disabled + mock_learner_can_preview_verified_content.return_value = False + + # When I access them as an audit user + audit_student_outline = get_user_course_outline( + self.course_key, self.student, at_time + ) + + # Then verified-only content is removed from the outline for the audit user + assert verified_sequence not in audit_student_outline.sections[0].sequences + # ... and is not marked as previewable + assert audit_student_outline.previewable_sequences == set() + + verified_student_outline = get_user_course_outline( + self.course_key, self.verified_student, at_time + ) + global_staff_outline = get_user_course_outline( + self.course_key, self.global_staff, at_time + ) + + # For verified and staff, the outline is unchanged + assert verified_student_outline.sections == global_staff_outline.sections + + # ... and do not contain any previewable sequences + assert verified_student_outline.previewable_sequences == set() + assert global_staff_outline.previewable_sequences == set() + class OutlineProcessorTestCase(CacheIsolationTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @classmethod From 961557330564132a62a9aeb6a58281ac6baf24af Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 19:06:50 -0500 Subject: [PATCH 11/20] style: fix pycodestyle issues --- .../course_home_api/outline/tests/test_view.py | 9 +++++---- lms/djangoapps/course_home_api/toggles.py | 1 + .../content/learning_sequences/api/outlines.py | 2 +- .../learning_sequences/api/tests/test_outlines.py | 5 ++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index 9ad4d7caa6e4..6de5db83f94c 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -497,7 +497,7 @@ def test_verified_content_preview_disabled_integration(self, mock_preview_functi with self.store.bulk_operations(self.course.id): chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) sequential = BlockFactory.create( - category='sequential', + category='sequential', parent_location=chapter.location, display_name='Verified Sequential', group_access={ENROLLMENT_TRACK_PARTITION_ID: [2]} # restrict to verified only @@ -532,12 +532,12 @@ def test_verified_content_preview_enabled_marks_previewable_content(self, mock_o with self.store.bulk_operations(self.course.id): chapter = BlockFactory.create(category='chapter', parent_location=self.course.location) verified_sequential = BlockFactory.create( - category='sequential', + category='sequential', parent_location=chapter.location, display_name='Verified Sequential', ) regular_sequential = BlockFactory.create( - category='sequential', + category='sequential', parent_location=chapter.location, display_name='Regular Sequential' ) @@ -560,7 +560,7 @@ def test_verified_content_preview_enabled_marks_previewable_content(self, mock_o # ... with course_blocks populated course_blocks = response.data['course_blocks']["blocks"] - + for block in course_blocks: # ... and the verified only content is marked as preview only if UsageKey.from_string(block) in mock_course_outline.previewable_sequences: @@ -569,6 +569,7 @@ def test_verified_content_preview_enabled_marks_previewable_content(self, mock_o else: assert course_blocks[block].get('is_preview') is False + @ddt.ddt class SidebarBlocksTestViews(BaseCourseHomeTests): """ diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 9d7c76978ca9..5cf28a299c50 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -98,6 +98,7 @@ def audit_learner_verified_preview_is_enabled(course_key): """ return COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW.is_enabled(course_key) + def learner_can_preview_verified_content(course_key, user): """ Determine if an audit learner can preview verified content in a course. diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index 82ada22693ee..c8e64700572c 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -355,7 +355,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes with function_trace(f'learning_sequences.api.outline_processors.{name}'): # An exception is made for audit preview of verified content. - # We don't want to remove content here, instead we + # We don't want to remove content here, instead we # ... this will get marked later when assembling outline if name == 'enrollment_track_partitions' and user_can_preview_verified_content: preview_usage_keys |= processor.usage_keys_to_remove(full_course_outline) 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 2315ef1e5740..eafbf26a912d 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 @@ -242,7 +242,7 @@ def test_audit_preview_of_verified_content_enabled(self, mock_learner_can_previe audit_outline = self.simple_outline verified_sequence = attr.evolve( audit_outline.sections[0].sequences[0], - user_partition_groups = { + user_partition_groups={ ENROLLMENT_TRACK_PARTITION_ID: [2] # restrict to verified only } ) @@ -278,7 +278,6 @@ def test_audit_preview_of_verified_content_enabled(self, mock_learner_can_previe assert verified_student_outline.previewable_sequences == set() assert global_staff_outline.previewable_sequences == set() - @patch('openedx.core.djangoapps.content.learning_sequences.api.outlines.learner_can_preview_verified_content') def test_audit_preview_of_verified_content_disabled(self, mock_learner_can_preview_verified_content): """ @@ -289,7 +288,7 @@ def test_audit_preview_of_verified_content_disabled(self, mock_learner_can_previ audit_outline = self.simple_outline verified_sequence = attr.evolve( audit_outline.sections[0].sequences[0], - user_partition_groups = { + user_partition_groups={ ENROLLMENT_TRACK_PARTITION_ID: [2] # restrict to verified only } ) From 06a298f684854f6369fe7ca2bad9584b19da07f4 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 19:10:27 -0500 Subject: [PATCH 12/20] style: fix pylint issues --- lms/djangoapps/course_home_api/outline/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index ac6655b92387..0c4557bb2e2d 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -36,7 +36,10 @@ ) from lms.djangoapps.course_home_api.utils import get_course_or_403 from lms.djangoapps.course_home_api.tasks import collect_progress_for_user_in_course -from lms.djangoapps.course_home_api.toggles import learner_can_preview_verified_content, send_course_progress_analytics_for_student_is_enabled +from lms.djangoapps.course_home_api.toggles import ( + learner_can_preview_verified_content, + send_course_progress_analytics_for_student_is_enabled, +) from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section From 34e270d49d2454de68c8297169eda2b9d8e39b84 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 19:19:27 -0500 Subject: [PATCH 13/20] style: fix line too long --- .../content/learning_sequences/api/tests/test_outlines.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 eafbf26a912d..a0ebb8acc218 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 @@ -203,7 +203,9 @@ def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called # Enroll beta tester in the course cls.beta_tester.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit") # Enroll verified student in the course as verified - cls.verified_student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode=CourseMode.VERIFIED) + cls.verified_student.courseenrollment_set.create( + course_id=cls.course_key, is_active=True, mode=CourseMode.VERIFIED + ) def test_simple_outline(self): """This outline is the same for everyone.""" From efc774a239e6b4321240e2b81d12df257250bde0 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 24 Nov 2025 20:40:43 -0500 Subject: [PATCH 14/20] refactor: remove LMS / common coupling The linters don't like core openedx code talking to LMS/CMS code. This keeps the toggle in LMS but passes the toggle value via kwargs to the functions that use them. --- lms/djangoapps/course_home_api/outline/views.py | 3 ++- .../content/learning_sequences/api/outlines.py | 17 +++++++++++------ .../api/tests/test_outlines.py | 16 +++++----------- .../content/learning_sequences/services.py | 9 ++++++--- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 0c4557bb2e2d..fdaa3cfbf320 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -313,7 +313,8 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements # so this is a tiny first step in that migration. if course_blocks: user_course_outline = get_user_course_outline( - course_key, request.user, datetime.now(tz=timezone.utc) + course_key, request.user, datetime.now(tz=timezone.utc), + preview_verified_content=allow_preview_of_verified_content ) available_seq_ids = {str(usage_key) for usage_key in user_course_outline.sequences} diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index c8e64700572c..cda3eda1555a 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -12,7 +12,6 @@ from django.db.models.query import QuerySet from edx_django_utils.cache import TieredCache from edx_django_utils.monitoring import function_trace, set_custom_attribute -from lms.djangoapps.course_home_api.toggles import learner_can_preview_verified_content from opaque_keys import OpaqueKey from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator @@ -259,17 +258,23 @@ def get_content_errors(course_key: CourseKey) -> List[ContentErrorData]: @function_trace('learning_sequences.api.get_user_course_outline') def get_user_course_outline(course_key: CourseKey, user: types.User, - at_time: datetime) -> UserCourseOutlineData: + at_time: datetime, + preview_verified_content: bool = False) -> UserCourseOutlineData: """ Get an outline customized for a particular user at a particular time. `user` is a Django User object (including the AnonymousUser) `at_time` should be a UTC datetime.datetime object. + If `preview_verified_content` is True, an audit user will be able to see the + presence of verified content even if they are not enrolled in verified mode. + See the definition of UserCourseOutlineData for details about the data returned. """ - user_course_outline, _ = _get_user_course_outline_and_processors(course_key, user, at_time) + user_course_outline, _ = _get_user_course_outline_and_processors( + course_key, user, at_time, preview_verified_content=preview_verified_content + ) return user_course_outline @@ -303,7 +308,8 @@ def get_user_course_outline_details(course_key: CourseKey, def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnesty, pylint: disable=missing-function-docstring user: types.User, - at_time: datetime): + at_time: datetime, + preview_verified_content: bool = False): """ Helper function that runs the outline processors. @@ -319,7 +325,6 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes full_course_outline = get_course_outline(course_key) user_can_see_all_content = can_see_all_content(user, course_key) - user_can_preview_verified_content = learner_can_preview_verified_content(course_key, user) # These are processors that alter which sequences are visible to students. # For instance, certain sequences that are intentionally hidden or not yet @@ -357,7 +362,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes # An exception is made for audit preview of verified content. # We don't want to remove content here, instead we # ... this will get marked later when assembling outline - if name == 'enrollment_track_partitions' and user_can_preview_verified_content: + if preview_verified_content: preview_usage_keys |= processor.usage_keys_to_remove(full_course_outline) continue 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 a0ebb8acc218..ecd1ce282998 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 @@ -238,8 +238,7 @@ def test_simple_outline(self): ) assert global_staff_outline_details.outline == global_staff_outline - @patch('openedx.core.djangoapps.content.learning_sequences.api.outlines.learner_can_preview_verified_content') - def test_audit_preview_of_verified_content_enabled(self, mock_learner_can_preview_verified_content): + def test_audit_preview_of_verified_content_enabled(self): # Given an outline where some content is restricted to verified only audit_outline = self.simple_outline verified_sequence = attr.evolve( @@ -253,18 +252,15 @@ def test_audit_preview_of_verified_content_enabled(self, mock_learner_can_previe at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) # ... and where the audit learner verified preview feature is enabled - mock_learner_can_preview_verified_content.return_value = True - # When I access them as an audit user audit_student_outline = get_user_course_outline( - self.course_key, self.student, at_time + self.course_key, self.student, at_time, preview_verified_content=True ) # Then verified-only content is marked as previewable for the audit user assert verified_sequence.usage_key in audit_student_outline.previewable_sequences # When I access them as a verified user, which would disable this preview check - mock_learner_can_preview_verified_content.return_value = False verified_student_outline = get_user_course_outline( self.course_key, self.verified_student, at_time ) @@ -280,8 +276,7 @@ def test_audit_preview_of_verified_content_enabled(self, mock_learner_can_previe assert verified_student_outline.previewable_sequences == set() assert global_staff_outline.previewable_sequences == set() - @patch('openedx.core.djangoapps.content.learning_sequences.api.outlines.learner_can_preview_verified_content') - def test_audit_preview_of_verified_content_disabled(self, mock_learner_can_preview_verified_content): + def test_audit_preview_of_verified_content_disabled(self): """ This outline has verified content that an audit user can preview only when the feature is enabled. @@ -299,11 +294,10 @@ def test_audit_preview_of_verified_content_disabled(self, mock_learner_can_previ at_time = datetime(2020, 5, 21, tzinfo=timezone.utc) # ... and where the audit learner verified preview feature is disabled - mock_learner_can_preview_verified_content.return_value = False - # When I access them as an audit user audit_student_outline = get_user_course_outline( - self.course_key, self.student, at_time + self.course_key, self.student, at_time, + preview_verified_content=False ) # Then verified-only content is removed from the outline for the audit user diff --git a/openedx/core/djangoapps/content/learning_sequences/services.py b/openedx/core/djangoapps/content/learning_sequences/services.py index a43d6ddd598c..e1c1a1402f8a 100644 --- a/openedx/core/djangoapps/content/learning_sequences/services.py +++ b/openedx/core/djangoapps/content/learning_sequences/services.py @@ -2,7 +2,6 @@ Learning Sequences Runtime Service """ - from .api import get_user_course_outline, get_user_course_outline_details @@ -17,8 +16,12 @@ def get_user_course_outline_details(self, course_key, user, at_time): """ return get_user_course_outline_details(course_key, user, at_time) - def get_user_course_outline(self, course_key, user, at_time): + def get_user_course_outline( + self, course_key, user, at_time, preview_verified_content=False + ): """ Returns UserCourseOutlineData """ - return get_user_course_outline(course_key, user, at_time) + return get_user_course_outline( + course_key, user, at_time, preview_verified_content=preview_verified_content + ) From d3a247e35de4a03a14cbf0cf97543387ed9638aa Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Tue, 25 Nov 2025 11:29:48 -0500 Subject: [PATCH 15/20] fix: correctly implement verified mode check in toggle --- .../course_home_api/tests/test_toggles.py | 50 +++++++++++++++---- lms/djangoapps/course_home_api/toggles.py | 2 +- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/course_home_api/tests/test_toggles.py b/lms/djangoapps/course_home_api/tests/test_toggles.py index b21b86e22e2d..048b74afb5b0 100644 --- a/lms/djangoapps/course_home_api/tests/test_toggles.py +++ b/lms/djangoapps/course_home_api/tests/test_toggles.py @@ -1,12 +1,14 @@ """ Tests for Course Home API toggles. """ + from unittest.mock import Mock, patch from django.test import TestCase from opaque_keys.edx.keys import CourseKey from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory from ..toggles import learner_can_preview_verified_content @@ -23,16 +25,40 @@ def setUp(self): self.feature_enabled_patcher = patch( "lms.djangoapps.course_home_api.toggles.audit_learner_verified_preview_is_enabled" ) - self.has_verified_mode_patcher = patch( - "common.djangoapps.course_modes.models.CourseMode.has_verified_mode" + self.verified_mode_for_course_patcher = patch( + "common.djangoapps.course_modes.models.CourseMode.verified_mode_for_course" ) self.get_enrollment_patcher = patch( "common.djangoapps.student.models.CourseEnrollment.get_enrollment" ) + # Course set up with verified, professional, and audit modes + self.verified_mode = CourseModeFactory( + course_id=self.course_key, + mode_slug=CourseMode.VERIFIED, + mode_display_name="Verified", + ) + self.professional_mode = CourseModeFactory( + course_id=self.course_key, + mode_slug=CourseMode.PROFESSIONAL, + mode_display_name="Professional", + ) + self.audit_mode = CourseModeFactory( + course_id=self.course_key, + mode_slug=CourseMode.AUDIT, + mode_display_name="Audit", + ) + self.course_modes_dict = { + "audit": self.audit_mode, + "verified": self.verified_mode, + "professional": self.professional_mode, + } + # Start patchers self.mock_feature_enabled = self.feature_enabled_patcher.start() - self.mock_has_verified_mode = self.has_verified_mode_patcher.start() + self.mock_verified_mode_for_course = ( + self.verified_mode_for_course_patcher.start() + ) self.mock_get_enrollment = self.get_enrollment_patcher.start() def _enroll_user(self, mode): @@ -44,14 +70,16 @@ def _enroll_user(self, mode): def tearDown(self): """Clean up patchers.""" self.feature_enabled_patcher.stop() - self.has_verified_mode_patcher.stop() + self.verified_mode_for_course_patcher.stop() self.get_enrollment_patcher.stop() def test_all_conditions_met_returns_true(self): """Test that function returns True when all conditions are met.""" # Given the feature is enabled, course has verified mode, and user is enrolled as audit self.mock_feature_enabled.return_value = True - self.mock_has_verified_mode.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] self._enroll_user(CourseMode.AUDIT) # When I check if the learner can preview verified content @@ -66,7 +94,9 @@ def test_feature_disabled_returns_false(self): self.mock_feature_enabled.return_value = False # ... even if all other conditions are met - self.mock_has_verified_mode.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] self._enroll_user(CourseMode.AUDIT) # When I check if the learner can preview verified content @@ -78,10 +108,10 @@ def test_feature_disabled_returns_false(self): def test_no_verified_mode_returns_false(self): """Test that function returns False when course has no verified mode.""" # Given the course does not have a verified mode - self.mock_has_verified_mode.return_value = False + self.mock_verified_mode_for_course.return_value = None # ... even if all other conditions are met - self.mock_feature_enabled.return_value = True + self.mock_feature_enabled.return_value = self.course_modes_dict["professional"] self._enroll_user(CourseMode.AUDIT) # When I check if the learner can preview verified content @@ -97,7 +127,7 @@ def test_no_enrollment_returns_false(self): # ... even if all other conditions are met self.mock_feature_enabled.return_value = True - self.mock_has_verified_mode.return_value = True + self.mock_verified_mode_for_course.return_value = True # When I check if the learner can preview verified content result = learner_can_preview_verified_content(self.course_key, self.user) @@ -112,7 +142,7 @@ def test_verified_enrollment_returns_false(self): # ... even if all other conditions are met self.mock_feature_enabled.return_value = True - self.mock_has_verified_mode.return_value = True + self.mock_verified_mode_for_course.return_value = True # When I check if the learner can preview verified content result = learner_can_preview_verified_content(self.course_key, self.user) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 5cf28a299c50..0dc814aa35c0 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -115,7 +115,7 @@ def learner_can_preview_verified_content(course_key, user): return False # ... the the course must have a verified mode - course_has_verified_mode = CourseMode.has_verified_mode(course_key) + course_has_verified_mode = CourseMode.verified_mode_for_course(course_key) if not course_has_verified_mode: return False From 85f5f3dc2aa348b9ef862a009b0dba2038a267e1 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Tue, 25 Nov 2025 11:35:01 -0500 Subject: [PATCH 16/20] fix: restore incorrectly removed check for enrollment track partition --- .../djangoapps/content/learning_sequences/api/outlines.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index cda3eda1555a..c91971f0fc67 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -360,9 +360,9 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes with function_trace(f'learning_sequences.api.outline_processors.{name}'): # An exception is made for audit preview of verified content. - # We don't want to remove content here, instead we - # ... this will get marked later when assembling outline - if preview_verified_content: + # Where enabled, we selectively disable the enrollment track partition processor + # so audit learners can preview (see presence of, but not access) of other track content. + if name == 'enrollment_track_partitions' and preview_verified_content: preview_usage_keys |= processor.usage_keys_to_remove(full_course_outline) continue From 6cf31a480201f208cbd04e63100d8d8d87cd110b Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Tue, 25 Nov 2025 11:35:07 -0500 Subject: [PATCH 17/20] docs: fix docstring --- lms/djangoapps/course_home_api/toggles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 0dc814aa35c0..f53584c81a9f 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -114,7 +114,7 @@ def learner_can_preview_verified_content(course_key, user): if not feature_enabled: return False - # ... the the course must have a verified mode + # ... the course must have a verified mode course_has_verified_mode = CourseMode.verified_mode_for_course(course_key) if not course_has_verified_mode: return False From 04635ad025da96568831af1eddd69bfd9df17b28 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Tue, 25 Nov 2025 13:45:52 -0500 Subject: [PATCH 18/20] test: fix test typos --- lms/djangoapps/course_home_api/tests/test_toggles.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/course_home_api/tests/test_toggles.py b/lms/djangoapps/course_home_api/tests/test_toggles.py index 048b74afb5b0..46ab545d0ade 100644 --- a/lms/djangoapps/course_home_api/tests/test_toggles.py +++ b/lms/djangoapps/course_home_api/tests/test_toggles.py @@ -111,7 +111,7 @@ def test_no_verified_mode_returns_false(self): self.mock_verified_mode_for_course.return_value = None # ... even if all other conditions are met - self.mock_feature_enabled.return_value = self.course_modes_dict["professional"] + self.mock_feature_enabled.return_value = True self._enroll_user(CourseMode.AUDIT) # When I check if the learner can preview verified content @@ -127,7 +127,9 @@ def test_no_enrollment_returns_false(self): # ... even if all other conditions are met self.mock_feature_enabled.return_value = True - self.mock_verified_mode_for_course.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] # When I check if the learner can preview verified content result = learner_can_preview_verified_content(self.course_key, self.user) @@ -142,7 +144,9 @@ def test_verified_enrollment_returns_false(self): # ... even if all other conditions are met self.mock_feature_enabled.return_value = True - self.mock_verified_mode_for_course.return_value = True + self.mock_verified_mode_for_course.return_value = self.course_modes_dict[ + "professional" + ] # When I check if the learner can preview verified content result = learner_can_preview_verified_content(self.course_key, self.user) From 5cd98d86d29fd1c473d379b37c5462bf6728c04a Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Tue, 25 Nov 2025 13:48:26 -0500 Subject: [PATCH 19/20] fix: correct nesting of previewable sequence marking --- .../course_home_api/outline/views.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index fdaa3cfbf320..78d5767ffeed 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -344,18 +344,18 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements ) ] if 'children' in chapter_data else [] - # For audit preview of verified content, we don't remove verified content. - # Instead, we mark it as preview so the frontend can handle it appropriately. - if allow_preview_of_verified_content: - previewable_sequences = {str(usage_key) for usage_key in user_course_outline.previewable_sequences} - - # Iterate through course_blocks to mark previewable sequences and chapters - for chapter_data in course_blocks['children']: - if chapter_data['id'] in previewable_sequences: - chapter_data['is_preview'] = True - for seq_data in chapter_data.get('children', []): - if seq_data['id'] in previewable_sequences: - seq_data['is_preview'] = True + # For audit preview of verified content, we don't remove verified content. + # Instead, we mark it as preview so the frontend can handle it appropriately. + if allow_preview_of_verified_content: + previewable_sequences = {str(usage_key) for usage_key in user_course_outline.previewable_sequences} + + # Iterate through course_blocks to mark previewable sequences and chapters + for chapter_data in course_blocks['children']: + if chapter_data['id'] in previewable_sequences: + chapter_data['is_preview'] = True + for seq_data in chapter_data.get('children', []): + if seq_data['id'] in previewable_sequences: + seq_data['is_preview'] = True user_has_passing_grade = False if not request.user.is_anonymous: From 35f643fddc855331a352f07309d4df7ca667b0c9 Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 1 Dec 2025 12:14:22 -0500 Subject: [PATCH 20/20] feat: add caching to learner toggle --- lms/djangoapps/course_home_api/toggles.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index f53584c81a9f..1f2d32b87e96 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -3,6 +3,7 @@ """ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag +from openedx.core.lib.cache_utils import request_cached from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -99,6 +100,7 @@ def audit_learner_verified_preview_is_enabled(course_key): return COURSE_HOME_AUDIT_LEARNER_VERIFIED_PREVIEW.is_enabled(course_key) +@request_cached() def learner_can_preview_verified_content(course_key, user): """ Determine if an audit learner can preview verified content in a course.