diff --git a/openedx/core/djangoapps/enrollments/services.py b/openedx/core/djangoapps/enrollments/services.py index b23dfa192cb5b08cf753fc0126229b4da6aa7e46..8b81db3c1ccd0cb88be0a2da5da9cc6811d9b2b5 100644 --- a/openedx/core/djangoapps/enrollments/services.py +++ b/openedx/core/djangoapps/enrollments/services.py @@ -1,9 +1,16 @@ """ Enrollments Service """ +from functools import reduce +from operator import or_ +from django.conf import settings +from django.db.models import Q +from opaque_keys.edx.keys import CourseKey +from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment +from xmodule.modulestore.django import modulestore class EnrollmentsService(object): @@ -12,9 +19,67 @@ class EnrollmentsService(object): Provides functions related to course enrollments """ - def get_active_enrollments_by_course(self, course_id): """ Returns a list of active enrollments for a course """ - return list(CourseEnrollment.objects.filter(course_id=course_id, is_active=True)) + return CourseEnrollment.objects.filter(course_id=course_id, is_active=True) + + def _get_enrollments_for_course_proctoring_eligible_modes(self, course_id, allow_honor_mode=False): + """ + Return all enrollments for a course that are in a mode that makes the corresponding user + eligible to take proctored exams. + + Parameters: + * course_id: course ID for the course + * allow_honor_mode: represents whether the course allows users with enrollments + in the honor mode are eligible to take proctored exams + """ + enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True) + + # We only want to get enrollments in paid modes. + appropriate_modes = [ + CourseMode.VERIFIED, + CourseMode.MASTERS, + CourseMode.PROFESSIONAL, + CourseMode.EXECUTIVE_EDUCATION, + ] + + # If the proctoring provider allows learners in honor mode to take exams, include it in the filter. + if allow_honor_mode: + appropriate_modes.append(CourseMode.HONOR) + + modes_filters = reduce(or_, [Q(mode=mode) for mode in appropriate_modes]) + + enrollments = enrollments.filter(modes_filters) + return enrollments + + def get_enrollments_can_take_proctored_exams(self, course_id, text_search=None): + """ + Return all enrollments for a course that are in a mode that makes the corresponding user + eligible to take proctored exams. + + NOTE: Due to performance concerns, this method returns a QuerySet. Ordinarily, model implementations + should not be exposed to clients in this way. However, the clients may need to do additional computation + in the database to avoid performance penalties. + + Parameters: + * course_id: course ID for the course + * text_search: the string against which to do a match on users' username or email; optional + """ + course_id_coursekey = CourseKey.from_string(course_id) + course_module = modulestore().get_course(course_id_coursekey) + if not course_module or not course_module.enable_proctored_exams: + return None + + allow_honor_mode = settings.PROCTORING_BACKENDS.get( + course_module.proctoring_provider, {} + ).get('allow_honor_mode', False) + enrollments = self._get_enrollments_for_course_proctoring_eligible_modes(course_id, allow_honor_mode) + + enrollments = enrollments.select_related('user') + if text_search: + user_filters = Q(user__username__icontains=text_search) | Q(user__email__icontains=text_search) + enrollments = enrollments.filter(user_filters) + + return enrollments diff --git a/openedx/core/djangoapps/enrollments/tests/test_services.py b/openedx/core/djangoapps/enrollments/tests/test_services.py index eeda97da071feac55d036952ecc55b0dcbea9bef..7ad37a5552894ba2de1abc35dbc479ee7e15fd35 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_services.py +++ b/openedx/core/djangoapps/enrollments/tests/test_services.py @@ -1,7 +1,7 @@ """ Enrollments Service Tests """ - +import ddt from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -12,6 +12,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +@ddt.ddt class EnrollmentsServiceTests(ModuleStoreTestCase): """ Tests for Enrollments Service @@ -19,38 +20,189 @@ class EnrollmentsServiceTests(ModuleStoreTestCase): def setUp(self): super().setUp() self.service = EnrollmentsService() - self.course = CourseFactory.create() - self.course_modes = [CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT] - for x in range(3): - CourseModeFactory.create(mode_slug=self.course_modes[x], course_id=self.course.id) - user = UserFactory(username='user{}'.format(x)) - CourseEnrollment.enroll(user, self.course.id, mode=self.course_modes[x]) - - def test_get_active_enrollments_by_course(self): + self.course_modes = [ + CourseMode.AUDIT, + CourseMode.EXECUTIVE_EDUCATION, + CourseMode.HONOR, + CourseMode.MASTERS, + CourseMode.PROFESSIONAL, + CourseMode.VERIFIED + ] + self.course = CourseFactory.create(enable_proctored_exams=True) + + for index in range(len(self.course_modes)): + course_mode = self.course_modes[index] + course_id = self.course.id + + CourseModeFactory.create(mode_slug=course_mode, course_id=course_id) + user = UserFactory( + username='user{}'.format(index), + email='LEARNER{}@example.com'.format(index) + ) + CourseEnrollment.enroll(user, course_id, mode=course_mode) + + def enrollment_to_dict(self, enrollment): + return {'username': enrollment.username, 'mode': enrollment.mode} + + def test_get_enrollments_can_take_proctored_exams_by_course(self): """ Test that it returns a list of active enrollments """ - enrollments = self.service.get_active_enrollments_by_course(self.course.id) - assert len(enrollments) == 3 - # At minimum, the function should return the user and mode tied to each enrollment - for x in range(3): - assert enrollments[x].user.username == 'user{}'.format(x) - assert enrollments[x].mode == self.course_modes[x] + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(self.course.id)) - def test_get_active_enrollments_by_course_ignore_inactive(self): + expected_values = [ + {'username': 'user1', 'mode': 'executive-education'}, + {'username': 'user3', 'mode': 'masters'}, + {'username': 'user4', 'mode': 'professional'}, + {'username': 'user5', 'mode': 'verified'} + ] + self.assertQuerysetEqual(enrollments, expected_values, self.enrollment_to_dict) + + def test_get_enrollments_can_take_proctored_exams_by_course_ignore_inactive(self): """ Test that inactive enrollments are ignored """ - inactive_enrollment = CourseEnrollment.objects.get(course_id=self.course.id, user__username='user0') + inactive_enrollment = CourseEnrollment.objects.get(course_id=self.course.id, user__username='user1') inactive_enrollment.is_active = False inactive_enrollment.save() - enrollments = self.service.get_active_enrollments_by_course(self.course.id) - assert len(enrollments) == 2 - def test_get_active_enrollments_no_enrollments(self): + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(self.course.id)) + + assert len(enrollments) == 3 + + def test_get_enrollments_can_take_proctored_exams_no_enrollments(self): """ Test that an empty list is returned if a course has no enrollments """ - new_course = CourseFactory() - enrollments = self.service.get_active_enrollments_by_course(new_course.id) # pylint: disable=no-member - assert len(enrollments) == 0 + course = CourseFactory.create(enable_proctored_exams=True) + + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(course.id)) # pylint: disable=no-member + + assert not enrollments.exists() + + def test_get_enrollments_can_take_proctored_exams_allow_honor(self): + self.course.proctoring_provider = 'test' + self.store.update_item(self.course, self.user.id) + + mock_proctoring_backend = { + 'test': { + 'allow_honor_mode': True + } + } + with self.settings(PROCTORING_BACKENDS=mock_proctoring_backend): + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(self.course.id)) + + expected_values = [ + {'username': 'user1', 'mode': 'executive-education'}, + {'username': 'user2', 'mode': 'honor'}, + {'username': 'user3', 'mode': 'masters'}, + {'username': 'user4', 'mode': 'professional'}, + {'username': 'user5', 'mode': 'verified'} + + ] + self.assertQuerysetEqual(enrollments, expected_values, self.enrollment_to_dict) + + def test_get_enrollments_can_take_proctored_exams_not_enable_proctored_exams(self): + self.course.enable_proctored_exams = False + self.store.update_item(self.course, self.user.id) + + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(self.course.id)) + + assert enrollments is None + + def test_get_enrollments_can_take_proctored_exams_no_course(self): + enrollments = self.service.get_enrollments_can_take_proctored_exams('org.0/course_0/Run_100') + + assert enrollments is None + + @ddt.data('ser', 'uSeR', 'leaRNer', 'LEARNER', '@example.com') + def test_text_search_partial_return_all(self, text_search): + enrollments = self.service.get_enrollments_can_take_proctored_exams( + str(self.course.id), + text_search=text_search + ) + + expected_values = [ + {'username': 'user1', 'mode': 'executive-education'}, + {'username': 'user3', 'mode': 'masters'}, + {'username': 'user4', 'mode': 'professional'}, + {'username': 'user5', 'mode': 'verified'} + ] + self.assertQuerysetEqual(enrollments, expected_values, self.enrollment_to_dict) + + def test_text_search_partial_return_some(self): + enrollments = self.service.get_enrollments_can_take_proctored_exams( + str(self.course.id), + text_search='3' + ) + + expected_values = [ + {'username': 'user3', 'mode': 'masters'} + ] + self.assertQuerysetEqual(enrollments, expected_values, self.enrollment_to_dict) + + @ddt.data('user1', 'USER1', 'LEARNER1@example.com', 'lEarNer1@eXAMPLE.com') + def test_text_search_exact_return_one(self, text_search): + enrollments = self.service.get_enrollments_can_take_proctored_exams( + str(self.course.id), + text_search=text_search + ) + + expected_values = [ + {'username': 'user1', 'mode': 'executive-education'} + ] + self.assertQuerysetEqual(enrollments, expected_values, self.enrollment_to_dict) + + def test_text_search_return_none(self): + enrollments = self.service.get_enrollments_can_take_proctored_exams( + str(self.course.id), + text_search='abc' + ) + + assert not enrollments.exists() + + +@ddt.ddt +class EnrollmentsServicePerformanceTests(ModuleStoreTestCase): + """ + Tests for Enrollments Service performance + """ + def setUp(self): + super().setUp() + self.service = EnrollmentsService() + self.course = CourseFactory.create(enable_proctored_exams=True) + self.course_modes = [ + CourseMode.AUDIT, + CourseMode.EXECUTIVE_EDUCATION, + CourseMode.HONOR, + CourseMode.MASTERS, + CourseMode.PROFESSIONAL, + CourseMode.VERIFIED, + ] + + for index in range(len(self.course_modes)): + CourseModeFactory.create(mode_slug=self.course_modes[index], course_id=self.course.id) + + def create_and_enroll_users(self, num_users): + num_course_modes = len(self.course_modes) + for index in range(num_users): + user = UserFactory(username='user{}'.format(index)) + CourseEnrollment.enroll(user, self.course.id, mode=self.course_modes[index % num_course_modes]) + + @ddt.data(10, 25, 50) + def test_get_enrollments_can_take_proctored_exams_num_queries(self, num_users): + self.create_and_enroll_users(num_users) + + with self.assertNumQueries(1): + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(self.course.id)) + # force execution of the QuerySet so that queries are exectued + repr(enrollments) + + @ddt.data(10, 25, 50) + def test_get_enrollments_can_take_proctored_exams_num_queries_text_search(self, num_users): + self.create_and_enroll_users(num_users) + + with self.assertNumQueries(1): + enrollments = self.service.get_enrollments_can_take_proctored_exams(str(self.course.id), text_search='edX') + # force execution of the QuerySet so that queries are exectued + repr(enrollments) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 2e9e23a31ccaecc78f3b4e7f73bbedd3ee77c43c..779e9be52b30cdca8a9fadfcc2ff38f1fbc043aa 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -106,7 +106,7 @@ edx-milestones==0.3.0 # via -r requirements/edx/base.in edx-opaque-keys[django]==2.2.0 # via -r requirements/edx/paver.txt, edx-bulk-grades, edx-ccx-keys, edx-completion, edx-drf-extensions, edx-enterprise, edx-milestones, edx-organizations, edx-proctoring, edx-user-state-client, edx-when, lti-consumer-xblock, xmodule edx-organizations==6.9.0 # via -r requirements/edx/base.in edx-proctoring-proctortrack==1.0.5 # via -r requirements/edx/base.in -edx-proctoring==3.7.1 # via -r requirements/edx/base.in, edx-proctoring-proctortrack +edx-proctoring==3.7.3 # via -r requirements/edx/base.in, edx-proctoring-proctortrack edx-rbac==1.4.1 # via edx-enterprise edx-rest-api-client==5.3.0 # via -r requirements/edx/base.in, edx-enterprise, edx-proctoring edx-search==3.0.0 # via -r requirements/edx/base.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 7cd13324a3dc265d4cae252e09ff26fee687b583..a307efef679b3dfeeed55b0b145a9cf12431591e 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -118,7 +118,7 @@ edx-milestones==0.3.0 # via -r requirements/edx/testing.txt edx-opaque-keys[django]==2.2.0 # via -r requirements/edx/testing.txt, edx-bulk-grades, edx-ccx-keys, edx-completion, edx-drf-extensions, edx-enterprise, edx-milestones, edx-organizations, edx-proctoring, edx-user-state-client, edx-when, lti-consumer-xblock, xmodule edx-organizations==6.9.0 # via -r requirements/edx/testing.txt edx-proctoring-proctortrack==1.0.5 # via -r requirements/edx/testing.txt -edx-proctoring==3.7.1 # via -r requirements/edx/testing.txt, edx-proctoring-proctortrack +edx-proctoring==3.7.3 # via -r requirements/edx/testing.txt, edx-proctoring-proctortrack edx-rbac==1.4.1 # via -r requirements/edx/testing.txt, edx-enterprise edx-rest-api-client==5.3.0 # via -r requirements/edx/testing.txt, edx-enterprise, edx-proctoring edx-search==3.0.0 # via -r requirements/edx/testing.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index d77cc6b31d74054f1e8169e8f86661ec8f10679a..5cb026eee46edeb4cedc08d384008d3646b2927b 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -115,7 +115,7 @@ edx-milestones==0.3.0 # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.2.0 # via -r requirements/edx/base.txt, edx-bulk-grades, edx-ccx-keys, edx-completion, edx-drf-extensions, edx-enterprise, edx-milestones, edx-organizations, edx-proctoring, edx-user-state-client, edx-when, lti-consumer-xblock, xmodule edx-organizations==6.9.0 # via -r requirements/edx/base.txt edx-proctoring-proctortrack==1.0.5 # via -r requirements/edx/base.txt -edx-proctoring==3.7.1 # via -r requirements/edx/base.txt, edx-proctoring-proctortrack +edx-proctoring==3.7.3 # via -r requirements/edx/base.txt, edx-proctoring-proctortrack edx-rbac==1.4.1 # via -r requirements/edx/base.txt, edx-enterprise edx-rest-api-client==5.3.0 # via -r requirements/edx/base.txt, edx-enterprise, edx-proctoring edx-search==3.0.0 # via -r requirements/edx/base.txt