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

Check failure on line 166 in lms/djangoapps/course_home_api/outline/tests/test_view.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (UP017)

lms/djangoapps/course_home_api/outline/tests/test_view.py:166:36: UP017 Use `datetime.UTC` alias help: Convert to `datetime.UTC` alias
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
@@ -1,75 +1,76 @@
"""
Outline Tab Views
"""
from datetime import datetime, timezone
from functools import cached_property

from completion.exceptions import UnavailableCompletionData # pylint: disable=wrong-import-order
from completion.models import BlockCompletion
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
from edx_django_utils import monitoring as monitoring_utils # pylint: disable=wrong-import-order
from edx_rest_framework_extensions.auth.jwt.authentication import (
JwtAuthentication, # pylint: disable=wrong-import-order
)
from edx_rest_framework_extensions.auth.session.authentication import (
SessionAuthenticationAllowInactiveUser, # pylint: disable=wrong-import-order
)
from opaque_keys.edx.keys import CourseKey # pylint: disable=wrong-import-order
from rest_framework.decorators import ( # pylint: disable=wrong-import-order
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.exceptions import APIException, ParseError # pylint: disable=wrong-import-order
from rest_framework.generics import RetrieveAPIView # pylint: disable=wrong-import-order
from rest_framework.permissions import IsAuthenticated # pylint: disable=wrong-import-order
from rest_framework.response import Response # pylint: disable=wrong-import-order
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock

from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.views import expose_header
from lms.djangoapps.course_goals.api import add_course_goal, get_course_goal
from lms.djangoapps.course_goals.models import CourseGoal
from lms.djangoapps.course_home_api.outline.serializers import CourseBlockSerializer, OutlineTabSerializer
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.utils import get_course_or_403
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 lms.djangoapps.courseware.date_summary import TodaysDate
from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade
from lms.djangoapps.courseware.tabs import DatesTab
from lms.djangoapps.courseware.toggles import courseware_disable_navigation_sidebar_blocks_caching
from lms.djangoapps.courseware.views.views import get_cert_data
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from lms.djangoapps.utils import OptimizelyClient
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_version
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_404
from openedx.core.djangoapps.content.learning_sequences.api import get_user_course_outline
from openedx.core.djangoapps.course_groups.cohorts import get_cohort
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.features.course_duration_limits.access import get_access_expiration_data
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, ENABLE_COURSE_GOALS
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
from openedx.features.course_experience.course_updates import (
dismiss_current_update_for_user,
get_current_update_for_user,
)
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
from openedx.features.course_experience.utils import get_course_outline_block_tree, get_start_block
from openedx.features.discounts.utils import generate_offer_data
from xmodule.course_block import ( # pylint: disable=wrong-import-order
COURSE_VISIBILITY_PUBLIC,
COURSE_VISIBILITY_PUBLIC_OUTLINE,
)

Check failure on line 73 in lms/djangoapps/course_home_api/outline/views.py

View workflow job for this annotation

GitHub Actions / Quality Others (ubuntu-24.04, 3.12, 20)

ruff (I001)

lms/djangoapps/course_home_api/outline/views.py:4:1: I001 Import block is un-sorted or un-formatted help: Organize imports


class UnableToDismissWelcomeMessage(APIException):
Expand Down Expand Up @@ -193,25 +194,35 @@
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 @@
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 @@
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 @@
'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 @@
# 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 @@
] 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
Loading