From 287a7ff7fd8509762e47acc1e8f1a33054484a94 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Mon, 11 Dec 2023 15:49:30 -0800 Subject: [PATCH] feat: add new course access error_code for enterprise learners in future courses Normally, the course API would return an access error_code of `course_not_started` if the course has not started yet. This change breaks that up into two codes: * if the course has not started: * return error_code=`course_not_started_enterprise_learner` if the learner is enrolled as a subsidized enterprise learner. * else, return error_code=`course_not_started`. This supports a change to the frontend which will interpret `course_not_started_enterprise_learner` differently and trigger a redirect to the enterprise (B2B) learner dashboard instead of the B2C dashboard. ENT-8078 --- lms/djangoapps/courseware/access_response.py | 27 +++++++ lms/djangoapps/courseware/access_utils.py | 26 +++++++ .../courseware/tests/test_access.py | 78 ++++++++++++++++++- lms/djangoapps/courseware/tests/test_views.py | 70 +++++++++++++++-- 4 files changed, 195 insertions(+), 6 deletions(-) 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..9c0bee999615 100644 --- a/lms/djangoapps/courseware/access_utils.py +++ b/lms/djangoapps/courseware/access_utils.py @@ -20,6 +20,7 @@ DataSharingConsentRequiredAccessError, EnrollmentRequiredAccessError, IncorrectActiveEnterpriseAccessError, + StartDateEnterpriseLearnerError, StartDateError ) from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student @@ -75,6 +76,7 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error Returns: AccessResponse: Either ACCESS_GRANTED or StartDateError. """ + from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data start_dates_disabled = settings.FEATURES['DISABLE_START_DATES'] masquerading_as_student = is_masquerading_as_student(user, course_key) @@ -92,6 +94,30 @@ 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 user.is_authenticated: + # 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(get_current_request()) + learner_portal_enabled = enterprise_customer_data and enterprise_customer_data['enable_learner_portal'] + if learner_portal_enabled: + # 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'], + ) + if enterprise_enrollments.exists(): + # Finally, we have established: + # * 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. + # + # We are now safe to kick the learner over to the enterprise learner dashboard. + 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..b4101a94d84a 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,71 @@ 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}) + 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..6d991d181e10 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,70 @@ 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}) + 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):