Skip to content

Commit

Permalink
Merge pull request openedx#33947 from openedx/pwnage101/ENT-8078-3
Browse files Browse the repository at this point in the history
feat: add new course access error_code for enterprise learners in future courses (2nd try)
  • Loading branch information
pwnage101 authored Dec 19, 2023
2 parents 7a15d36 + 9ef8ce6 commit 9c48e38
Show file tree
Hide file tree
Showing 5 changed files with 235 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
51 changes: 51 additions & 0 deletions lms/djangoapps/courseware/access_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,6 +21,7 @@
DataSharingConsentRequiredAccessError,
EnrollmentRequiredAccessError,
IncorrectActiveEnterpriseAccessError,
StartDateEnterpriseLearnerError,
StartDateError
)
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down
79 changes: 78 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,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))
71 changes: 66 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,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):
Expand Down
13 changes: 13 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 9c48e38

Please sign in to comment.