Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
80da588
feat: add toggle for audit preview of verified content
nsprenkle Nov 19, 2025
72a6b05
docs: fix incorrect docstring
nsprenkle Nov 19, 2025
67a6b1f
feat: add extended logic for when a user / course should allow audit …
nsprenkle Nov 19, 2025
2a1bc07
feat: when audit preview of verified content is enabled, mark non-aud…
nsprenkle Nov 19, 2025
e2c95be
feat: mark previewable sections for audit learners who can preview ve…
nsprenkle Nov 19, 2025
935a53f
style: fix typo in name
nsprenkle Nov 21, 2025
3c2141e
refactor: more efficient verified preview check
nsprenkle Nov 24, 2025
84d0f0c
test: add tests for Course Home API toggle logic
nsprenkle Nov 24, 2025
ca0f6c1
test: add tests for outline view with audit preview
nsprenkle Nov 24, 2025
ac59048
test: add tests for outline processing with verified preveiw
nsprenkle Nov 25, 2025
9615573
style: fix pycodestyle issues
nsprenkle Nov 25, 2025
06a298f
style: fix pylint issues
nsprenkle Nov 25, 2025
34e270d
style: fix line too long
nsprenkle Nov 25, 2025
efc774a
refactor: remove LMS / common coupling
nsprenkle Nov 25, 2025
d3a247e
fix: correctly implement verified mode check in toggle
nsprenkle Nov 25, 2025
85f5f3d
fix: restore incorrectly removed check for enrollment track partition
nsprenkle Nov 25, 2025
6cf31a4
docs: fix docstring
nsprenkle Nov 25, 2025
04635ad
test: fix test typos
nsprenkle Nov 25, 2025
5cd98d8
fix: correct nesting of previewable sequence marking
nsprenkle Nov 25, 2025
35f643f
feat: add caching to learner toggle
nsprenkle Dec 1, 2025
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
1 change: 1 addition & 0 deletions lms/djangoapps/course_home_api/outline/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'):
Expand Down
85 changes: 85 additions & 0 deletions lms/djangoapps/course_home_api/outline/tests/test_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,6 +44,7 @@
BlockFactory,
CourseFactory
)
from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID


@ddt.ddt
Expand Down Expand Up @@ -484,6 +486,89 @@ 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):
Expand Down
22 changes: 20 additions & 2 deletions lms/djangoapps/course_home_api/outline/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down Expand Up @@ -209,6 +212,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_verified_content(course_key, request.user)

# User locale settings
user_timezone_locale = user_timezone_locale_prefs(request)
Expand Down Expand Up @@ -309,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}

Expand Down Expand Up @@ -339,6 +344,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)
Expand Down
155 changes: 155 additions & 0 deletions lms/djangoapps/course_home_api/tests/test_toggles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""
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


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.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_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):
"""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.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_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
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_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
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_verified_mode_for_course.return_value = None

# ... 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_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)

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

# Then the result should be False
self.assertFalse(result)
55 changes: 55 additions & 0 deletions lms/djangoapps/course_home_api/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"""

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

WAFFLE_FLAG_NAMESPACE = 'course_home'

Expand Down Expand Up @@ -51,6 +54,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
Expand All @@ -73,3 +91,40 @@ 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)


@request_cached()
def learner_can_preview_verified_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.
"""
# 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

# ... 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

# ... 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 True
Loading
Loading