diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 1ec5962f63ed..199d547f7d43 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -612,7 +612,16 @@ def completions_dict(self): Dictionary keys are block keys and values are int values representing the completion status of the block. + + Anonymous users (who can reach this view on public courses via the + COURSE_ENABLE_UNENROLLED_ACCESS_FLAG waffle) cannot own BlockCompletion + rows, so short-circuit with an empty mapping. Querying BlockCompletion + with an AnonymousUser otherwise raises TypeError because the FK + coercion cannot cast AnonymousUser to an integer primary key. + See bug #38019. """ + if self.request.user.is_anonymous: + return {} course_key_string = self.kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) completions = BlockCompletion.objects.filter(user=self.request.user, context_key=course_key).values_list( diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index c49a52688020..e0e4f9e49a97 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -56,6 +56,7 @@ ACCESS_DENIED, ACCESS_GRANTED, check_course_open_for_learner, + check_public_access, check_start_date, debug, ) @@ -67,6 +68,7 @@ from xmodule.course_block import ( # lint-amnesty, pylint: disable=wrong-import-order CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_CATALOG_AND_ABOUT, + COURSE_VISIBILITY_PUBLIC, CourseBlock, ) from xmodule.error_block import ErrorBlock # lint-amnesty, pylint: disable=wrong-import-order @@ -366,6 +368,14 @@ def can_load(): if courselike.id.deprecated: # we no longer support accessing Old Mongo courses return OldMongoAccessError(courselike) + # Anonymous users on a public course (with the unenrolled-access waffle + # enabled) must be allowed to load the course even when the course has + # a future start date. Without this, check_course_open_for_learner + # below returns StartDateError and render_xblock converts it to 404, + # breaking anonymous video viewing on public courses. See bug #38019. + if user.is_anonymous and check_public_access(courselike, [COURSE_VISIBILITY_PUBLIC]): + return ACCESS_GRANTED + visible_to_nonstaff = _visible_to_nonstaff_users(courselike) if not visible_to_nonstaff: staff_access = _has_staff_access_to_block(user, courselike, courselike.id) @@ -609,6 +619,17 @@ def can_load(): if not group_access_response: return group_access_response + # Anonymous users on a public course must be able to load blocks even + # when the block's (inherited) start date is in the future. The course + # level check is already handled in _has_access_course; mirror that + # bypass here so block-level checks agree (bug #38019). We still honor + # group access (checked above) so cohort/partition gating continues + # to apply. + if user.is_anonymous and course_key is not None: + course_overview = CourseOverview.get_from_id(course_key) + if check_public_access(course_overview, [COURSE_VISIBILITY_PUBLIC]): + return ACCESS_GRANTED + # If the user has staff access, they can load the block and checks below are not needed. staff_access_response = _has_staff_access_to_block(user, block, course_key) if staff_access_response: diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index b00cca78f2e1..bc2a95b05222 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -48,7 +48,10 @@ from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.core.djangolib.testing.utils import AUTHZ_TABLES from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.course_experience import ENFORCE_MASQUERADE_START_DATES +from openedx.features.course_experience import ( + COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, + ENFORCE_MASQUERADE_START_DATES, +) from openedx.features.enterprise_support.api import add_enterprise_customer_to_session from openedx.features.enterprise_support.tests.factories import ( EnterpriseCourseEnrollmentFactory, @@ -729,6 +732,77 @@ def test__catalog_visibility_returns_typed_error(self): assert isinstance(see_in_catalog_response, access_response.CatalogVisibilityError) assert access._has_access_course(user, 'see_about_page', course_about) + @override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=True) + def test_unit_anonymous_can_load_public_course_with_future_start(self): + """ + Unit regression for bug #38019: anonymous users on public courses + with a future start date must be granted load access. Previously + denied by check_course_open_for_learner -> StartDateError. + """ + from xmodule.course_block import COURSE_VISIBILITY_PUBLIC + future_course = CourseFactory.create( + org='edX', course='public38019', run='future', + start=self.DATES[self.TOMORROW], + course_visibility=COURSE_VISIBILITY_PUBLIC, + ) + response = access._has_access_course(self.anonymous_user, 'load', future_course) + assert bool(response) is True + + @override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=False) + def test_unit_anonymous_denied_when_unenrolled_access_flag_off(self): + """ + Guard rail: disabling the unenrolled-access waffle must still deny + anonymous users even on a public course. Bug #38019. + """ + from xmodule.course_block import COURSE_VISIBILITY_PUBLIC + future_course = CourseFactory.create( + org='edX', course='public38019', run='flagoff', + start=self.DATES[self.TOMORROW], + course_visibility=COURSE_VISIBILITY_PUBLIC, + ) + response = access._has_access_course(self.anonymous_user, 'load', future_course) + assert bool(response) is False + + @override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=True) + def test_integration_anonymous_can_load_block_on_public_course(self): + """ + Integration regression for bug #38019: anonymous block-level load + access must succeed on public courses even when the block inherits + a future start date. + """ + from xmodule.course_block import COURSE_VISIBILITY_PUBLIC + future_course = CourseFactory.create( + org='edX', course='public38019', run='blocktest', + start=self.DATES[self.TOMORROW], + course_visibility=COURSE_VISIBILITY_PUBLIC, + ) + mock_block = Mock( + user_partitions=[], + group_access={}, + start=self.DATES[self.TOMORROW], + days_early_for_beta=None, + merged_group_access={}, + visible_to_staff_only=False, + location=future_course.id.make_usage_key('video', 'sample'), + ) + response = access._has_access_to_block( + self.anonymous_user, 'load', mock_block, course_key=future_course.id, + ) + assert bool(response) is True + + def test_bug_38019_regression_anonymous_still_denied_on_non_public_future(self): + """ + Regression for bug #38019: the public-access bypass must not leak + to non-public courses. Anonymous users must still be denied on + non-public courses with future start dates. + """ + future_course = CourseFactory.create( + org='edX', course='private38019', run='future', + start=self.DATES[self.TOMORROW], + ) + response = access._has_access_course(self.anonymous_user, 'load', future_course) + assert bool(response) is False + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) @override_settings(MILESTONES_APP=True) def test_access_on_course_with_pre_requisites(self):