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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user