diff --git a/lms/djangoapps/courseware/access_response.py b/lms/djangoapps/courseware/access_response.py index abfdf61db2c6..323a8c8f6426 100644 --- a/lms/djangoapps/courseware/access_response.py +++ b/lms/djangoapps/courseware/access_response.py @@ -144,6 +144,33 @@ def __init__(self, start_date, display_error_to_user=True): ) +class StartDateEnterpriseLearnerError(AccessError): + """ + Access denied because the course has not started yet and the user is not staff. Use this error when this user is + also an enterprise learner and enrolled in the requested course. + """ + def __init__(self, start_date, display_error_to_user=True): + """ + Arguments: + display_error_to_user: If True, display this error to users in the UI. + """ + error_code = "course_not_started_enterprise_learner" + if start_date == DEFAULT_START_DATE: + developer_message = "Course has not started, and the learner is enrolled via an enterprise subsidy." + user_message = _("Course has not started") + else: + developer_message = ( + f"Course does not start until {start_date}, and the learner is enrolled via an enterprise subsidy." + ) + user_message = _("Course does not start until {}" # lint-amnesty, pylint: disable=translation-of-non-string + .format(f"{start_date:%B %d, %Y}")) + super().__init__( + error_code, + developer_message, + user_message if display_error_to_user else None + ) + + class MilestoneAccessError(AccessError): """ Access denied because the user has unfulfilled milestones diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py index 6e5699f4e27a..dd2f8d8f6dfe 100644 --- a/lms/djangoapps/courseware/access_utils.py +++ b/lms/djangoapps/courseware/access_utils.py @@ -9,6 +9,7 @@ from crum import get_current_request from django.conf import settings +from edx_toggles.toggles import SettingToggle from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser from pytz import UTC @@ -20,6 +21,7 @@ DataSharingConsentRequiredAccessError, EnrollmentRequiredAccessError, IncorrectActiveEnterpriseAccessError, + StartDateEnterpriseLearnerError, StartDateError ) from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student @@ -64,6 +66,48 @@ def adjust_start_date(user, days_early_for_beta, start, course_key): return start +def enterprise_learner_enrolled(request, user, course_key): + """ + Determine if the learner should be redirected to the enterprise learner portal by checking their enterprise + memberships/enrollments. If all of the following are true, then we are safe to redirect the learner: + + * The learner is linked to an enterprise customer, + * The enterprise customer has subsidized the learner's enrollment in the requested course, + * The enterprise customer has the learner portal enabled. + + NOTE: This function MUST be called from a view, or it will throw an exception. + + Args: + request (django.http.HttpRequest): The current request being handled. Must not be None. + user (User): The requesting enter, potentially an enterprise learner. + course_key (str): The requested course to check for enrollment. + + Returns: + bool: True if the learner is enrolled via a linked enterprise customer and can safely be redirected to the + enterprise learner dashboard. + """ + from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data + + if not user.is_authenticated: + return False + + # enterprise_customer_data is either None (if learner is not linked to any customer) or a serialized + # EnterpriseCustomer representing the learner's active linked customer. + enterprise_customer_data = enterprise_customer_from_session_or_learner_data(request) + learner_portal_enabled = enterprise_customer_data and enterprise_customer_data['enable_learner_portal'] + if not learner_portal_enabled: + return False + + # Additionally make sure the enterprise learner is actually enrolled in the requested course, subsidized + # via the discovered customer. + enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter( + course_id=course_key, + enterprise_customer_user__user_id=user.id, + enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_data['uuid'], + ) + return enterprise_enrollments.exists() + + def check_start_date(user, days_early_for_beta, start, course_key, display_error_to_user=True, now=None): """ Verifies whether the given user is allowed access given the @@ -92,6 +136,13 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error if should_grant_access: return ACCESS_GRANTED + # Before returning a StartDateError, determine if the learner should be redirected to the enterprise learner + # portal by returning StartDateEnterpriseLearnerError instead. + if SettingToggle('COURSEWARE_COURSE_NOT_STARTED_ENTERPRISE_LEARNER_ERROR', default=False).is_enabled(): + request = get_current_request() + if request and enterprise_learner_enrolled(request, user, course_key): + return StartDateEnterpriseLearnerError(start, display_error_to_user=display_error_to_user) + return StartDateError(start, display_error_to_user=display_error_to_user) diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index ba93ec676205..e89b4f6c4ba0 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -57,6 +57,14 @@ ) from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order from xmodule.partitions.partitions import MINIMUM_STATIC_PARTITION_ID, Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order +from openedx.features.enterprise_support.api import add_enterprise_customer_to_session +from enterprise.api.v1.serializers import EnterpriseCustomerSerializer +from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCourseEnrollmentFactory, + EnterpriseCustomerUserFactory, + EnterpriseCustomerFactory +) +from crum import set_current_request QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES @@ -847,7 +855,7 @@ def test_course_overview_unsupported_action(self): ) @ddt.unpack @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - def test_course_catalog_access_num_queries(self, user_attr_name, action, course_attr_name): + def test_course_catalog_access_num_queries_no_enterprise(self, user_attr_name, action, course_attr_name): ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2018, 1, 1)) course = getattr(self, course_attr_name) @@ -886,3 +894,72 @@ def test_course_catalog_access_num_queries(self, user_attr_name, action, course_ course_overview = CourseOverview.get_from_id(course.id) with self.assertNumQueries(num_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): bool(access.has_access(user, action, course_overview, course_key=course.id)) + + @ddt.data( + *itertools.product( + ['user_normal', 'user_staff', 'user_anonymous'], + ['course_started', 'course_not_started'], + ) + ) + @ddt.unpack + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False, 'ENABLE_ENTERPRISE_INTEGRATION': True}) + @override_settings(COURSEWARE_COURSE_NOT_STARTED_ENTERPRISE_LEARNER_ERROR=True) + def test_course_catalog_access_num_queries_enterprise(self, user_attr_name, course_attr_name): + """ + Similar to test_course_catalog_access_num_queries_no_enterprise, except enable enterprise features and make the + basic enrollment look like an enterprise-subsidized enrollment, setting up one of each: + + * EnterpriseCustomer + * EnterpriseCustomerUser + * EnterpriseCourseEnrollment + * A mock request session to pre-cache the enterprise customer data. + """ + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2018, 1, 1)) + + course = getattr(self, course_attr_name) + + request = RequestFactory().get('/') + request.session = {} + + # get a fresh user object that won't have any cached role information + if user_attr_name == 'user_anonymous': + user = AnonymousUserFactory() + request.user = user + else: + user = getattr(self, user_attr_name) + user = User.objects.get(id=user.id) + request.user = user + course_enrollment = CourseEnrollmentFactory(user=user, course_id=course.id) + enterprise_customer = EnterpriseCustomerFactory(enable_learner_portal=True) + add_enterprise_customer_to_session(request, EnterpriseCustomerSerializer(enterprise_customer).data) + enterprise_customer_user = EnterpriseCustomerUserFactory( + user_id=user.id, + enterprise_customer=enterprise_customer, + ) + EnterpriseCourseEnrollmentFactory(enterprise_customer_user=enterprise_customer_user, course_id=course.id) + set_current_request(request) + + if user_attr_name == 'user_staff': + if course_attr_name == 'course_started': + # read: CourseAccessRole + django_comment_client.Role + num_queries = 2 + else: + # read: CourseAccessRole + EnterpriseCourseEnrollment + num_queries = 2 + elif user_attr_name == 'user_normal': + if course_attr_name == 'course_started': + # read: CourseAccessRole + django_comment_client.Role + FBEEnrollmentExclusion + CourseMode + num_queries = 4 + else: + # read: CourseAccessRole + CourseEnrollmentAllowed + EnterpriseCourseEnrollment + num_queries = 3 + elif user_attr_name == 'user_anonymous': + if course_attr_name == 'course_started': + # read: CourseMode + num_queries = 1 + else: + num_queries = 0 + + course_overview = CourseOverview.get_from_id(course.id) + with self.assertNumQueries(num_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + bool(access.has_access(user, 'see_exists', course_overview, course_key=course.id)) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index e170e857666e..8b7667ec2710 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -110,7 +110,14 @@ get_learning_mfe_home_url, make_learning_mfe_courseware_url ) +from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCourseEnrollmentFactory, + EnterpriseCustomerUserFactory, + EnterpriseCustomerFactory +) from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired +from openedx.features.enterprise_support.api import add_enterprise_customer_to_session +from enterprise.api.v1.serializers import EnterpriseCustomerSerializer QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES @@ -3223,17 +3230,71 @@ class AccessUtilsTestCase(ModuleStoreTestCase): Test access utilities """ @ddt.data( - (1, False), - (-1, True) + { + 'start_date_modifier': 1, # course starts in future + 'setup_enterprise_enrollment': False, + 'expected_has_access': False, + 'expected_error_code': 'course_not_started', + }, + { + 'start_date_modifier': -1, # course already started + 'setup_enterprise_enrollment': False, + 'expected_has_access': True, + 'expected_error_code': None, + }, + { + 'start_date_modifier': 1, # course starts in future + 'setup_enterprise_enrollment': True, + 'expected_has_access': False, + 'expected_error_code': 'course_not_started_enterprise_learner', + }, + { + 'start_date_modifier': -1, # course already started + 'setup_enterprise_enrollment': True, + 'expected_has_access': True, + 'expected_error_code': None, + }, ) @ddt.unpack - @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - def test_is_course_open_for_learner(self, start_date_modifier, expected_value): + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False, 'ENABLE_ENTERPRISE_INTEGRATION': True}) + @override_settings(COURSEWARE_COURSE_NOT_STARTED_ENTERPRISE_LEARNER_ERROR=True) + def test_is_course_open_for_learner( + self, + start_date_modifier, + setup_enterprise_enrollment, + expected_has_access, + expected_error_code, + ): + """ + Test is_course_open_for_learner(). + + When setup_enterprise_enrollment == True, make an enterprise-subsidized enrollment, setting up one of each: + * CourseEnrollment + * EnterpriseCustomer + * EnterpriseCustomerUser + * EnterpriseCourseEnrollment + * A mock request session to pre-cache the enterprise customer data. + """ staff_user = AdminFactory() start_date = datetime.now(UTC) + timedelta(days=start_date_modifier) course = CourseFactory.create(start=start_date) + request = RequestFactory().get('/') + request.user = staff_user + request.session = {} + if setup_enterprise_enrollment: + course_enrollment = CourseEnrollmentFactory(mode=CourseMode.VERIFIED, user=staff_user, course_id=course.id) + enterprise_customer = EnterpriseCustomerFactory(enable_learner_portal=True) + add_enterprise_customer_to_session(request, EnterpriseCustomerSerializer(enterprise_customer).data) + enterprise_customer_user = EnterpriseCustomerUserFactory( + user_id=staff_user.id, + enterprise_customer=enterprise_customer, + ) + EnterpriseCourseEnrollmentFactory(enterprise_customer_user=enterprise_customer_user, course_id=course.id) + set_current_request(request) - assert bool(check_course_open_for_learner(staff_user, course)) == expected_value + access_response = check_course_open_for_learner(staff_user, course) + assert bool(access_response) == expected_has_access + assert access_response.error_code == expected_error_code class DatesTabTestCase(TestCase): diff --git a/lms/envs/common.py b/lms/envs/common.py index ed8d520660c9..58eeac2d6025 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4677,6 +4677,19 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = {} ENTERPRISE_TAGLINE = '' +# .. toggle_name: COURSEWARE_COURSE_NOT_STARTED_ENTERPRISE_LEARNER_ERROR +# .. toggle_implementation: SettingToggle +# .. toggle_default: False +# .. toggle_description: If disabled (False), this switch causes the CourseTabView API (or whatever else calls +# check_course_open_for_learner()) to always return the legacy `course_not_started` error code in ALL cases where the +# course has not started. If enabled (True), the API will respond with `course_not_started_enterprise_learner` in a +# subset of cases where the learner is enrolled via subsidy, and `course_not_started` in all other cases. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-12-18 +# .. toggle_target_removal_date: 2023-12-19 +# .. toggle_tickets: ENT-8078 +COURSEWARE_COURSE_NOT_STARTED_ENTERPRISE_LEARNER_ERROR = False + ############## Settings for Course Enrollment Modes ###################### # The min_price key refers to the minimum price allowed for an instance # of a particular type of course enrollment mode. This is not to be confused