Merge pull request #33947 from openedx/pwnage101/ENT-8078-3

feat: add new course access error_code for enterprise learners in future courses (2nd try)
This commit is contained in:
Troy Sankey
2023-12-19 08:50:42 -08:00
committed by GitHub
5 changed files with 235 additions and 6 deletions

View File

@@ -144,6 +144,33 @@ class StartDateError(AccessError):
)
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

View File

@@ -9,6 +9,7 @@ from logging import getLogger
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 @@ from lms.djangoapps.courseware.access_response import (
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)

View File

@@ -57,6 +57,14 @@ from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: di
)
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 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
)
@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 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase):
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))

View File

@@ -110,7 +110,14 @@ from openedx.features.course_experience.url_helpers import (
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):

View File

@@ -4677,6 +4677,19 @@ DATA_CONSENT_SHARE_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
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