From 3f5cbcfc6a01ea3277d60ee007d08d2e129fcb34 Mon Sep 17 00:00:00 2001 From: Bianca Severino Date: Wed, 3 Feb 2021 14:41:12 -0500 Subject: [PATCH] Check for an existing proctored exam before sending proctoring requirements email --- common/djangoapps/student/email_helpers.py | 24 ++++++++++ common/djangoapps/student/models.py | 8 ++-- .../student/tests/test_enrollment.py | 47 +++++++++++++++++-- openedx/core/djangoapps/enrollments/api.py | 17 +++++-- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/common/djangoapps/student/email_helpers.py b/common/djangoapps/student/email_helpers.py index 7e18a8d682..c35739aa8d 100644 --- a/common/djangoapps/student/email_helpers.py +++ b/common/djangoapps/student/email_helpers.py @@ -9,6 +9,7 @@ from django.conf import settings from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.ace_common.template_context import get_base_template_context +from openedx.core.djangoapps.enrollments.api import is_enrollment_valid_for_proctoring from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from xmodule.modulestore.django import modulestore @@ -51,3 +52,26 @@ def generate_proctoring_requirements_email_context(user, course_id): 'proctoring_requirements_url': settings.PROCTORING_SETTINGS.get('LINK_URLS', {}).get('faq', ''), 'id_verification_url': IDVerificationService.get_verify_location(), } + + +def should_send_proctoring_requirements_email(username, course_id): + """ + Returns a boolean whether a proctoring requirements email should be sent. + + Arguments: + * username (str): The user associated with the enrollment. + * course_id (str): The course id associated with the enrollment. + """ + if not is_enrollment_valid_for_proctoring(username, course_id): + return False + + # Only send if a proctored exam is found in the course + timed_exams = modulestore().get_items( + course_id, + qualifiers={'category': 'sequential'}, + settings={'is_time_limited': True} + ) + + has_proctored_exam = any([exam.is_proctored_exam for exam in timed_exams]) + + return has_proctored_exam diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index bf41171fd2..2bd714a092 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -59,7 +59,10 @@ from user_util import user_util import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price from common.djangoapps.student.emails import send_proctoring_requirements_email -from common.djangoapps.student.email_helpers import generate_proctoring_requirements_email_context +from common.djangoapps.student.email_helpers import ( + generate_proctoring_requirements_email_context, + should_send_proctoring_requirements_email +) from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, UNENROLL_DONE from common.djangoapps.track import contexts, segment from common.djangoapps.util.model_utils import emit_field_changed_events, get_changed_fields_dict @@ -76,7 +79,6 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi from openedx.core.djangoapps.enrollments.api import ( _default_course_mode, get_enrollment_attributes, - is_enrollment_valid_for_proctoring, set_enrollment_attributes, ) from openedx.core.djangoapps.signals.signals import USER_ACCOUNT_ACTIVATED @@ -1461,7 +1463,7 @@ class CourseEnrollment(models.Model): if mode_changed: if COURSEWARE_PROCTORING_IMPROVEMENTS.is_enabled(self.course_id): # If mode changed to one that requires proctoring, send proctoring requirements email - if is_enrollment_valid_for_proctoring(self.user.username, self.course_id): + if should_send_proctoring_requirements_email(self.user.username, self.course_id): email_context = generate_proctoring_requirements_email_context(self.user, self.course_id) send_proctoring_requirements_email(context=email_context) diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index a885711a50..7c8fdfdf4a 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -26,7 +26,7 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFac from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @ddt.ddt @@ -48,7 +48,12 @@ class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase): super(EnrollmentTest, cls).setUpClass() cls.course = CourseFactory.create() cls.course_limited = CourseFactory.create() - cls.proctored_course = CourseFactory(enable_proctored_exams=True) + cls.proctored_course = CourseFactory( + enable_proctored_exams=True, enable_timed_exams=True + ) + cls.proctored_course_no_exam = CourseFactory( + enable_proctored_exams=True, enable_timed_exams=True + ) @patch.dict(settings.FEATURES, {'EMBARGO': True}) def setUp(self): @@ -61,6 +66,21 @@ class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase): self.urls = [ reverse('course_modes_choose', kwargs={'course_id': six.text_type(self.course.id)}) ] + # Set up proctored exam + self._create_proctored_exam(self.proctored_course) + + def _create_proctored_exam(self, course): + """ + Helper function to create a proctored exam for a given course + """ + chapter = ItemFactory.create( + parent=course, category='chapter', display_name='Test Section', publish_item=True + ) + ItemFactory.create( + parent=chapter, category='sequential', display_name='Test Proctored Exam', + graded=True, is_time_limited=True, default_time_limit_minutes=10, + is_proctored_exam=True, publish_item=True + ) @ddt.data( # Default (no course modes in the database) @@ -197,10 +217,24 @@ class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase): CourseEnrollment.enroll(self.user, self.proctored_course.id, mode) # pylint: disable=no-member self.assertEqual(email_sent, mock_send_email.called) + def test_enroll_in_proctored_course_no_exam(self): + """ + If a verified learner enrolls in a course that has proctoring enabled, but does not have + any proctored exams, they should not receive a proctoring requirements email. + """ + with patch( + 'common.djangoapps.student.models.send_proctoring_requirements_email', + return_value=None + ) as mock_send_email: + CourseEnrollment.enroll( + self.user, self.proctored_course_no_exam.id, 'verified' # pylint: disable=no-member + ) + self.assertFalse(mock_send_email.called) + @ddt.data('verified', 'masters', 'professional', 'executive-education') def test_upgrade_proctoring_enrollment(self, mode): """ - When upgrading from audit in a proctoring-enabled course, an email with proctoring requirements + When upgrading from audit in a course with proctored exams, an email with proctoring requirements should be sent. """ with patch( @@ -219,15 +253,18 @@ class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase): def test_enroll_in_proctored_course_honor_mode_allowed(self): """ If the proctoring provider allows honor mode, send proctoring requirements email when learners - enroll in honor mode for a proctoring-enabled course. + enroll in honor mode for a course with proctored exams. """ with patch( 'common.djangoapps.student.models.send_proctoring_requirements_email', return_value=None ) as mock_send_email: course_honor_mode = CourseFactory( - enable_proctored_exams=True, proctoring_provider='test_provider_honor_mode' + enable_proctored_exams=True, + enable_timed_exams=True, + proctoring_provider='test_provider_honor_mode', ) + self._create_proctored_exam(course_honor_mode) CourseEnrollment.enroll(self.user, course_honor_mode.id, 'honor') # pylint: disable=no-member self.assertTrue(mock_send_email.called) diff --git a/openedx/core/djangoapps/enrollments/api.py b/openedx/core/djangoapps/enrollments/api.py index 8d7647a893..1b09fe182a 100644 --- a/openedx/core/djangoapps/enrollments/api.py +++ b/openedx/core/djangoapps/enrollments/api.py @@ -497,25 +497,32 @@ def serialize_enrollments(enrollments): def is_enrollment_valid_for_proctoring(username, course_id): """ - Returns a boolean value regarding whether user's course enrollment is eligible for proctoring. Returns - False if the enrollment is not active, special exams aren't enabled, proctored exams aren't enabled - for the course, or if the course mode is audit. + Returns a boolean value regarding whether user's course enrollment is eligible for proctoring. + + Returns false if: + * special exams aren't enabled + * the enrollment is not active + * proctored exams aren't enabled for the course + * the course mode is audit Arguments: - username: The user associated with the enrollment. - course_id (str): The course id associated with the enrollment. + * username (str): The user associated with the enrollment. + * course_id (str): The course id associated with the enrollment. """ if not settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): return False + # Verify that the learner's enrollment is active enrollment = _data_api().get_course_enrollment(username, str(course_id)) if not enrollment['is_active']: return False + # Check that the course has proctored exams enabled course_module = modulestore().get_course(course_id) if not course_module.enable_proctored_exams: return False + # Only allow verified modes appropriate_modes = [ CourseMode.VERIFIED, CourseMode.MASTERS, CourseMode.PROFESSIONAL, CourseMode.EXECUTIVE_EDUCATION ]