diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py index 23ec992f81..4a9ec90e4a 100644 --- a/lms/djangoapps/courseware/access_utils.py +++ b/lms/djangoapps/courseware/access_utils.py @@ -23,7 +23,11 @@ from lms.djangoapps.courseware.access_response import ( StartDateError, ) from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student -from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, COURSE_PRE_START_ACCESS_FLAG +from openedx.features.course_experience import ( + COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, + COURSE_PRE_START_ACCESS_FLAG, + ENFORCE_MASQUERADE_START_DATES, +) from xmodule.course_block import COURSE_VISIBILITY_PUBLIC # lint-amnesty, pylint: disable=wrong-import-order DEBUG_ACCESS = False @@ -137,7 +141,10 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error if start_dates_disabled and not masquerading_as_student: return ACCESS_GRANTED else: - if start is None or get_course_masquerade(user, course_key): + if start is None: + return ACCESS_GRANTED + + if not ENFORCE_MASQUERADE_START_DATES.is_enabled(course_key) and get_course_masquerade(user, course_key): return ACCESS_GRANTED if now is None: diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 5b910565e4..c763a7577a 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -17,6 +17,7 @@ from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from milestones.tests.utils import MilestonesTestCaseMixin from opaque_keys.edx.locator import CourseLocator @@ -30,6 +31,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.course_experience import ENFORCE_MASQUERADE_START_DATES from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseCcxCoachRole, CourseStaffRole from common.djangoapps.student.tests.factories import ( @@ -465,6 +467,50 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes self.verify_access(mock_unit, expected_access, expected_error_type) + @ddt.data( + # Flag inactive (default) + (False, True, None), # Masquerading, no start date + (False, True, YESTERDAY), # Masquerading, past start date + (False, False, TOMORROW), # Not masquerading, future start date + (False, True, TOMORROW), # Masquerading, future start date + # Flag active + (True, True, None), # Masquerading, no start date + (True, True, YESTERDAY), # Masquerading, past start date + (True, False, TOMORROW), # Not masquerading, future start date + (True, True, TOMORROW, False), # Masquerading, future start date - no access + ) + @ddt.unpack + @patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False}) + def test_enforce_masquerade_start_dates_flag(self, flag_active, is_masquerading, start, expected_access=True): + """ + Test that the ENFORCE_MASQUERADE_START_DATES flag controls whether masquerading bypasses start date + restrictions. + + When the flag is disabled (default), masquerading users bypass start dates. + When the flag is enabled, masquerading users see the same start date restrictions as regular students. + """ + mock_unit = Mock( + location=self.course.location, + user_partitions=[], + _class_tags={}, + start=self.DATES[start], + visible_to_staff_only=False, + merged_group_access={}, + ) + + if is_masquerading: + self.course_staff.masquerade_settings = {self.course.id: CourseMasquerade(self.course.id, role="student")} + + with override_waffle_flag(ENFORCE_MASQUERADE_START_DATES, active=flag_active): + response = access._has_access_to_block(self.course_staff, "load", mock_unit, course_key=self.course.id) + + if expected_access: + assert response == access.ACCESS_GRANTED + else: + assert isinstance(response, access_response.StartDateError) + assert response.to_json()["error_code"] is not None + assert str(self.DATES[start]) in response.developer_message + def test__has_access_course_can_enroll(self): yesterday = datetime.datetime.now(pytz.utc) - datetime.timedelta(days=1) tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1) diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index c5f11a3013..1305879f1c 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -83,6 +83,23 @@ RELATIVE_DATES_DISABLE_RESET_FLAG = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.r # .. toggle_tickets: https://openedx.atlassian.net/browse/AA-36 CALENDAR_SYNC_FLAG = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.calendar_sync', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation +# .. toggle_name: course_experience.enforce_masquerade_start_dates +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: When enabled, staff masquerading as students will see the same start date +# restrictions as actual students. This provides a more accurate preview experience by enforcing +# section and subsection start dates even when viewing the course as a masqueraded user. +# When disabled (default), masquerading continues to bypass start date restrictions as before. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2025-10-08 +# .. toggle_warning: Enabling this flag means staff members masquerading as students will not be able to access course +# content before its start date, which may impact course testing workflows. +# Also, when you masquerade as a student in a course that starts in the future, you will lock yourself out of the +# course in the current Django session. To revert this, you need to log out and log back in. +ENFORCE_MASQUERADE_START_DATES = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.enforce_masquerade_start_dates', __name__ +) + def course_home_page_title(_course): """