Skip to content
94 changes: 94 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 @@ -144,6 +144,100 @@ def test_course_staff_can_see_non_user_specific_content_in_masquerade(self):
assert response.data['course_blocks'] is not None
assert response.data['handouts_html'] is not None

def test_unreleased_content_hidden_when_masquerading_as_learner(self):
"""
Regression test: Verify that unreleased sections/units are excluded when masquerading as a specific learner.
This prevents staff from seeing hidden/future content while masquerading.
"""
# Create an enrolled learner
learner = UserFactory()
CourseEnrollment.enroll(learner, self.course.id)

# Create an instructor
instructor = UserFactory(
username='instructor',
email='instructor@example.com',
password='foo',
is_staff=False
)
CourseInstructorRole(self.course.id).add_users(instructor)

# Create a chapter with a future start date (unreleased)
future_date = datetime.now(timezone.utc) + timedelta(days=30)
chapter = self.store.create_item(
instructor.id,
self.course.id,
'chapter',
'unreleased_chapter',
fields={
'display_name': 'Future Chapter',
'start': future_date.isoformat(),
}
)

# Create a sequential inside the unreleased chapter
sequential = self.store.create_item(
instructor.id,
chapter.location,
'sequential',
'unreleased_seq',
fields={'display_name': 'Future Sequential'}
)

# Create a vertical inside the sequential
vertical = self.store.create_item(
instructor.id,
sequential.location,
'vertical',
'unreleased_vert',
fields={'display_name': 'Future Vertical'}
)

# Create a problem inside the vertical
self.store.create_item(
instructor.id,
vertical.location,
'problem',
'unreleased_problem',
fields={
'display_name': 'Future Problem',
'data': '<problem></problem>',
}
)

# Login as instructor
self.client.login(username=instructor, password='foo')

# First, verify instructor can see the unreleased content
response = self.client.get(self.url)
assert response.status_code == 200
staff_course_blocks = response.data['course_blocks']
staff_block_ids = self._get_all_block_ids_recursive(staff_course_blocks)
assert str(chapter.location) in staff_block_ids, "Staff should see unreleased chapters"

# Now masquerade as the learner
self.update_masquerade(username=learner.username)
response = self.client.get(self.url)
assert response.status_code == 200
learner_course_blocks = response.data['course_blocks']
learner_block_ids = self._get_all_block_ids_recursive(learner_course_blocks)

# Verify unreleased content is hidden from the masqueraded learner view
assert str(chapter.location) not in learner_block_ids, "Learner should NOT see unreleased chapters"
assert str(sequential.location) not in learner_block_ids, "Learner should NOT see unreleased sequentials"
assert str(vertical.location) not in learner_block_ids, "Learner should NOT see unreleased verticals"
assert str(vertical.location.replace(name='unreleased_problem')) not in learner_block_ids, \
"Learner should NOT see unreleased problems"

def _get_all_block_ids_recursive(self, block):
"""Helper to collect all block IDs from a course block tree"""
block_ids = set()
if block:
block_ids.add(block.get('id'))
for child in block.get('children', []):
block_ids.update(self._get_all_block_ids_recursive(child))
return block_ids

@override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=True)
def test_handouts(self):
CourseEnrollment.enroll(self.user, self.course.id)
Expand Down
47 changes: 29 additions & 18 deletions lms/djangoapps/course_home_api/outline/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from completion.utilities import get_key_to_last_completed_block # pylint: disable=wrong-import-order
from django.conf import settings # pylint: disable=wrong-import-order
from django.core.cache import cache
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404 # pylint: disable=wrong-import-order
from django.urls import reverse # pylint: disable=wrong-import-order
from django.utils.translation import gettext as _ # pylint: disable=wrong-import-order
Expand Down Expand Up @@ -193,25 +194,35 @@ class OutlineTabView(RetrieveAPIView):
def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
course_key_string = kwargs.get('course_key_string')
course_key = CourseKey.from_string(course_key_string)
user = request.user

# Enable NR tracing for this view based on course
monitoring_utils.set_custom_attribute('course_id', course_key_string)
monitoring_utils.set_custom_attribute('user_id', request.user.id)
monitoring_utils.set_custom_attribute('is_staff', request.user.is_staff)

course = get_course_or_403(request.user, 'load', course_key, check_if_enrolled=False)
course = get_course_or_403(user, 'load', course_key, check_if_enrolled=False)

masquerade_object, request.user = setup_masquerade(
request,
course_key,
staff_access=has_access(request.user, 'staff', course_key),
staff_access=has_access(user, 'staff', course_key),
reset_masquerade_data=True,
)

user_is_masquerading = is_masquerading(request.user, course_key, course_masquerade=masquerade_object)
user_is_masquerading = is_masquerading(user, course_key, course_masquerade=masquerade_object)
# Check if the user is masquerading as a student and get the masqueraded user object
if user_is_masquerading and masquerade_object.role == 'student':
try:
User = get_user_model()
# If the masqueraded user does not exist, we will continue with the original user object.
username = masquerade_object.user_name
user = User.objects.get(username=username)
except User.DoesNotExist:
pass

course_overview = get_course_overview_or_404(course_key)
enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
enrollment = CourseEnrollment.get_enrollment(user, course_key)
enrollment_mode = getattr(enrollment, 'mode', None)
allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key)
allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC
Expand Down Expand Up @@ -254,35 +265,35 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
show_enrolled = is_enrolled or is_staff
enable_proctored_exams = False
if show_enrolled:
course_blocks = get_course_outline_block_tree(request, course_key_string, request.user)
date_blocks = get_course_date_blocks(course, request.user, request, num_assignments=1)
course_blocks = get_course_outline_block_tree(request, course_key_string, user)
date_blocks = get_course_date_blocks(course, user, request, num_assignments=1)
Comment thread
hiteshkava marked this conversation as resolved.
course_date_blocks = (
[block for block in date_blocks if not isinstance(block, TodaysDate)]
if DatesTab.is_enabled(course, request.user)
if DatesTab.is_enabled(course, user)
else []
)
dates_widget['course_date_blocks'] = course_date_blocks

handouts_html = get_course_info_section(request, request.user, course, 'handouts')
handouts_html = get_course_info_section(request, user, course, 'handouts')
welcome_message_html = get_current_update_for_user(request, course)

offer_data = generate_offer_data(request.user, course_overview)
access_expiration = get_access_expiration_data(request.user, course_overview)
cert_data = get_cert_data(request.user, course, enrollment.mode) if is_enrolled else None
offer_data = generate_offer_data(user, course_overview)
access_expiration = get_access_expiration_data(user, course_overview)
cert_data = get_cert_data(user, course, enrollment.mode) if is_enrolled else None

enable_proctored_exams = course_overview.enable_proctored_exams

if (is_enrolled and ENABLE_COURSE_GOALS.is_enabled(course_key)):
course_goals['weekly_learning_goal_enabled'] = True
selected_goal = get_course_goal(request.user, course_key)
selected_goal = get_course_goal(user, course_key)
if selected_goal:
course_goals['selected_goal'] = {
'days_per_week': selected_goal.days_per_week,
'subscribed_to_reminders': selected_goal.subscribed_to_reminders,
}

try:
resume_block = get_key_to_last_completed_block(request.user, course.id)
resume_block = get_key_to_last_completed_block(user, course.id)
resume_course['has_visited_course'] = True
resume_path = reverse('jump_to', kwargs={
'course_id': course_key_string,
Expand All @@ -296,7 +307,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
elif allow_public_outline or allow_public or user_is_masquerading:
course_blocks = get_course_outline_block_tree(request, course_key_string, None)
if allow_public or user_is_masquerading:
handouts_html = get_course_info_section(request, request.user, course, 'handouts')
handouts_html = get_course_info_section(request, user, course, 'handouts')

if not is_enrolled:
if CourseMode.is_masters_only(course_key):
Expand All @@ -305,7 +316,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
'Please contact your degree administrator or '
'{platform_name} Support if you have questions.'
).format(platform_name=settings.PLATFORM_NAME)
elif CourseEnrollment.is_enrollment_closed(request.user, course_overview):
elif CourseEnrollment.is_enrollment_closed(user, course_overview):
enroll_alert['can_enroll'] = False
elif CourseEnrollment.objects.is_course_full(course_overview):
enroll_alert['can_enroll'] = False
Expand All @@ -321,7 +332,7 @@ 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) # noqa: UP017
course_key, user, datetime.now(tz=timezone.utc) # noqa: UP017
)
available_seq_ids = {str(usage_key) for usage_key in user_course_outline.sequences}

Expand Down Expand Up @@ -352,8 +363,8 @@ def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements
] if 'children' in chapter_data else []

user_has_passing_grade = False
if not request.user.is_anonymous:
user_grade = CourseGradeFactory().read(request.user, course)
if not user.is_anonymous:
user_grade = CourseGradeFactory().read(user, course)
if user_grade:
user_has_passing_grade = user_grade.passed

Expand Down