Skip to content

Commit

Permalink
Merge pull request #33913 from openedx/pwnage101/ENT-8078
Browse files Browse the repository at this point in the history
feat: add new course access error_code for enterprise learners in future courses
  • Loading branch information
macdiesel committed Dec 15, 2023
2 parents d7b7f70 + 287a7ff commit 8ec4b2b
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 6 deletions.
27 changes: 27 additions & 0 deletions lms/djangoapps/courseware/access_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions lms/djangoapps/courseware/access_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DataSharingConsentRequiredAccessError,
EnrollmentRequiredAccessError,
IncorrectActiveEnterpriseAccessError,
StartDateEnterpriseLearnerError,
StartDateError
)
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
Expand Down Expand Up @@ -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)

Expand All @@ -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)


Expand Down
78 changes: 77 additions & 1 deletion lms/djangoapps/courseware/tests/test_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
70 changes: 65 additions & 5 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down

0 comments on commit 8ec4b2b

Please sign in to comment.