diff --git a/common/djangoapps/student/email_helpers.py b/common/djangoapps/student/email_helpers.py new file mode 100644 index 0000000000..7e18a8d682 --- /dev/null +++ b/common/djangoapps/student/email_helpers.py @@ -0,0 +1,53 @@ +""" +Helpers for Student app emails. +""" + + +from string import capwords + +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.site_configuration import helpers as configuration_helpers +from xmodule.modulestore.django import modulestore + + +def generate_activation_email_context(user, registration): + """ + Constructs a dictionary for use in activation email contexts + + Arguments: + user (User): Currently logged-in user + registration (Registration): Registration object for the currently logged-in user + """ + context = get_base_template_context(None) + context.update({ + 'name': user.profile.name, + 'key': registration.activation_key, + 'lms_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'support_url': configuration_helpers.get_value( + 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK + ) or settings.SUPPORT_SITE_LINK, + 'support_email': configuration_helpers.get_value('CONTACT_EMAIL', settings.CONTACT_EMAIL), + }) + return context + + +def generate_proctoring_requirements_email_context(user, course_id): + """ + Constructs a dictionary for use in proctoring requirements email context + + Arguments: + user: Currently logged-in user + course_id: ID of the proctoring-enabled course the user is enrolled in + """ + course_module = modulestore().get_course(course_id) + return { + 'user': user, + 'course_name': course_module.display_name, + 'proctoring_provider': capwords(course_module.proctoring_provider.replace('_', ' ')), + 'proctoring_requirements_url': settings.PROCTORING_SETTINGS.get('LINK_URLS', {}).get('faq', ''), + 'id_verification_url': IDVerificationService.get_verify_location(), + } diff --git a/common/djangoapps/student/emails.py b/common/djangoapps/student/emails.py new file mode 100644 index 0000000000..a67739530a --- /dev/null +++ b/common/djangoapps/student/emails.py @@ -0,0 +1,34 @@ +""" +ACE emails for Student app +""" +import logging + +from django.conf import settings +from django.contrib.sites.models import Site +from edx_ace import ace +from edx_ace.recipient import Recipient + +from common.djangoapps.student.message_types import ProctoringRequirements +from openedx.core.djangoapps.ace_common.template_context import get_base_template_context + +log = logging.getLogger(__name__) + + +def send_proctoring_requirements_email(context): + """Send an email with proctoring requirements for a course enrollment""" + site = Site.objects.get_current() + message_context = get_base_template_context(site) + message_context.update(context) + user = context['user'] + try: + msg = ProctoringRequirements(context=message_context).personalize( + recipient=Recipient(user.username, user.email), + language=settings.LANGUAGE_CODE, + user_context={'full_name': user.profile.name} + ) + ace.send(msg) + log.info('Proctoring requirements email sent to user: %r', user.username) + return True + except Exception: # pylint: disable=broad-except + log.exception('Could not send email for proctoring requirements to user %s', user.username) + return False diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index 3f3a2ab865..da6b1eb274 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -25,17 +25,6 @@ from six import iteritems, text_type from common.djangoapps import third_party_auth from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.certificates.api import get_certificate_url, has_html_certificates_enabled -from lms.djangoapps.certificates.models import CertificateStatuses, certificate_status_for_student -from lms.djangoapps.grades.api import CourseGradeFactory -from lms.djangoapps.verify_student.models import VerificationDeadline -from lms.djangoapps.verify_student.services import IDVerificationService -from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime -from openedx.core.djangoapps.ace_common.template_context import get_base_template_context -from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.theming.helpers import get_themes -from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect from common.djangoapps.student.models import ( CourseEnrollment, LinkedInAddToProfileConfiguration, @@ -47,6 +36,16 @@ from common.djangoapps.student.models import ( username_exists_or_retired ) from common.djangoapps.util.password_policy_validators import normalize_password +from lms.djangoapps.certificates.api import get_certificate_url, has_html_certificates_enabled +from lms.djangoapps.certificates.models import CertificateStatuses, certificate_status_for_student +from lms.djangoapps.grades.api import CourseGradeFactory +from lms.djangoapps.verify_student.models import VerificationDeadline +from lms.djangoapps.verify_student.services import IDVerificationService +from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime +from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.helpers import get_themes +from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect # Enumeration of per-course verification statuses # we display on the student dashboard. @@ -385,28 +384,6 @@ def _get_redirect_to(request_host, request_headers, request_params, request_is_h return redirect_to -def generate_activation_email_context(user, registration): - """ - Constructs a dictionary for use in activation email contexts - - Arguments: - user (User): Currently logged-in user - registration (Registration): Registration object for the currently logged-in user - """ - context = get_base_template_context(None) - context.update({ - 'name': user.profile.name, - 'key': registration.activation_key, - 'lms_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'support_url': configuration_helpers.get_value( - 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK - ) or settings.SUPPORT_SITE_LINK, - 'support_email': configuration_helpers.get_value('CONTACT_EMAIL', settings.CONTACT_EMAIL), - }) - return context - - def create_or_set_user_attribute_created_on_site(user, site): """ Create or Set UserAttribute indicating the site the user account was created on. diff --git a/common/djangoapps/student/message_types.py b/common/djangoapps/student/message_types.py index 03c88eb5d9..f3a6442c3d 100644 --- a/common/djangoapps/student/message_types.py +++ b/common/djangoapps/student/message_types.py @@ -8,34 +8,41 @@ from openedx.core.djangoapps.ace_common.message import BaseMessageType class AccountRecovery(BaseMessageType): def __init__(self, *args, **kwargs): - super(AccountRecovery, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.options['transactional'] = True class EmailChange(BaseMessageType): def __init__(self, *args, **kwargs): - super(EmailChange, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.options['transactional'] = True class EmailChangeConfirmation(BaseMessageType): def __init__(self, *args, **kwargs): - super(EmailChangeConfirmation, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.options['transactional'] = True class RecoveryEmailCreate(BaseMessageType): def __init__(self, *args, **kwargs): - super(RecoveryEmailCreate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.options['transactional'] = True class AccountActivation(BaseMessageType): def __init__(self, *args, **kwargs): - super(AccountActivation, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + + self.options['transactional'] = True + + +class ProctoringRequirements(BaseMessageType): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.options['transactional'] = True diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 23246edbef..bf41171fd2 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -58,28 +58,32 @@ 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.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 +from common.djangoapps.util.query import use_read_replica_if_available from lms.djangoapps.certificates.models import GeneratedCertificate from lms.djangoapps.courseware.models import ( CourseDynamicUpgradeDeadlineConfiguration, DynamicUpgradeDeadlineConfiguration, - OrgDynamicUpgradeDeadlineConfiguration + OrgDynamicUpgradeDeadlineConfiguration, ) +from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import ( _default_course_mode, get_enrollment_attributes, - set_enrollment_attributes + is_enrollment_valid_for_proctoring, + set_enrollment_attributes, ) from openedx.core.djangoapps.signals.signals import USER_ACCOUNT_ACTIVATED from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.core.djangolib.model_mixins import DeletableByUserValue from openedx.core.toggles import ENTRANCE_EXAMS -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 -from common.djangoapps.util.query import use_read_replica_if_available log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") @@ -1455,6 +1459,12 @@ class CourseEnrollment(models.Model): self.send_signal(EnrollStatusChange.unenroll) 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): + email_context = generate_proctoring_requirements_email_context(self.user, self.course_id) + send_proctoring_requirements_email(context=email_context) + # Only emit mode change events when the user's enrollment # mode has changed from its previous setting self.emit_event(EVENT_NAME_ENROLLMENT_MODE_CHANGED) @@ -2586,7 +2596,7 @@ def log_successful_logout(sender, request, user, **kwargs): @receiver(user_logged_in) @receiver(user_logged_out) -def enforce_single_login(sender, request, user, signal, **kwargs): +def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: disable=unused-argument """ Sets the current session id in the user profile, to prevent concurrent logins. diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 9b0bdf72b3..f023b921e8 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -3,6 +3,7 @@ import json import unittest +from string import capwords import ddt import six @@ -15,26 +16,32 @@ from django.test import TransactionTestCase, override_settings from django.test.client import RequestFactory from django.urls import reverse from django.utils.html import escape +from edx_toggles.toggles.testutils import override_waffle_flag from mock import Mock, patch from six import text_type -from common.djangoapps.edxmako.shortcuts import marketing_link, render_to_string -from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme -from openedx.core.djangolib.testing.utils import CacheIsolationMixin, CacheIsolationTestCase -from openedx.core.lib.request_utils import safe_get_host +from common.djangoapps.edxmako.shortcuts import marketing_link +from common.djangoapps.student.email_helpers import generate_proctoring_requirements_email_context +from common.djangoapps.student.emails import send_proctoring_requirements_email from common.djangoapps.student.models import PendingEmailChange, Registration, UserProfile from common.djangoapps.student.tests.factories import PendingEmailChangeFactory, UserFactory from common.djangoapps.student.views import ( SETTING_CHANGE_INITIATED, confirm_email_change, do_email_change_request, - generate_activation_email_context, validate_new_email ) from common.djangoapps.third_party_auth.views import inactive_user_view from common.djangoapps.util.testing import EventTestMixin +from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS +from lms.djangoapps.verify_student.services import IDVerificationService +from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme +from openedx.core.djangolib.testing.utils import CacheIsolationMixin, CacheIsolationTestCase +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class TestException(Exception): @@ -215,6 +222,71 @@ class ActivationEmailTests(EmailTemplateTagMixin, CacheIsolationTestCase): self.assertEqual(email.called, True, msg='method should have been called') +@ddt.ddt +@override_waffle_flag(COURSEWARE_PROCTORING_IMPROVEMENTS, active=True) +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) +@override_settings(ACCOUNT_MICROFRONTEND_URL='http://account-mfe') +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") +class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCase): + """ + Test sending of the proctoring requirements email. + """ + # pylint: disable=no-member + def setUp(self): + super().setUp() + self.course = CourseFactory(enable_proctored_exams=True) + self.user = UserFactory() + + def test_send_proctoring_requirements_email(self): + context = generate_proctoring_requirements_email_context(self.user, self.course.id) + send_proctoring_requirements_email(context) + self._assert_email() + + def _assert_email(self): + """ + Verify that the email was sent. + """ + assert len(mail.outbox) == 1 + + message = mail.outbox[0] + text = message.body + html = message.alternatives[0][0] + + self.assertEqual( + message.subject, + "Proctoring requirements for {}".format(self.course.display_name) + ) + + for fragment in self._get_fragments(): + assert fragment in text + assert escape(fragment) in html + + def _get_fragments(self): + course_module = modulestore().get_course(self.course.id) + proctoring_provider = capwords(course_module.proctoring_provider.replace('_', ' ')) + id_verification_url = IDVerificationService.get_verify_location() + return [ + ( + "You are enrolled in {} at {}. This course contains proctored exams.".format( + self.course.display_name, + settings.PLATFORM_NAME + ) + ), + ( + "Proctored exams are timed exams that you take while proctoring software monitors " + "your computer's desktop, webcam video, and audio." + ), + proctoring_provider, + ( + "Carefully review the system requirements as well as the steps to take a proctored " + "exam in order to ensure that you are prepared." + ), + settings.PROCTORING_SETTINGS.get('LINK_URLS', {}).get('faq', ''), + ("Before taking a graded proctored exam, you must have approved ID verification photos."), + id_verification_url + ] + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") class EmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolationTestCase): """ diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 6511ef95cb..a885711a50 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -9,6 +9,7 @@ import ddt import six from django.conf import settings from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from mock import patch from common.djangoapps.course_modes.models import CourseMode @@ -23,11 +24,14 @@ from common.djangoapps.student.models import ( from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory 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 @ddt.ddt +@override_waffle_flag(COURSEWARE_PROCTORING_IMPROVEMENTS, active=True) +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase): """ @@ -44,6 +48,7 @@ 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) @patch.dict(settings.FEATURES, {'EMBARGO': True}) def setUp(self): @@ -164,6 +169,68 @@ class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase): else: self.assertFalse(mock_update_email_opt_in.called) + @ddt.data( + ('honor', False), + ('audit', False), + ('verified', True), + ('masters', True), + ('professional', True), + ('no-id-professional', False), + ('credit', False), + ('executive-education', True) + ) + @ddt.unpack + def test_enroll_in_proctored_course(self, mode, email_sent): + """ + When enrolling in a proctoring-enabled course in a verified mode, an email with proctoring + requirements should be sent. The email should not be sent for non-verified modes. + """ + with patch( + 'common.djangoapps.student.models.send_proctoring_requirements_email', + return_value=None + ) as mock_send_email: + # First enroll in a non-proctored course. This should not trigger the email. + CourseEnrollment.enroll(self.user, self.course.id, mode) + self.assertFalse(mock_send_email.called) + # Then, enroll in a proctored course, and assert that the email is sent only when + # enrolling in a verified mode. + CourseEnrollment.enroll(self.user, self.proctored_course.id, mode) # pylint: disable=no-member + self.assertEqual(email_sent, 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 + should be sent. + """ + with patch( + 'common.djangoapps.student.models.send_proctoring_requirements_email', + return_value=None + ) as mock_send_email: + enrollment = CourseEnrollment.enroll( + self.user, self.proctored_course.id, 'audit' # pylint: disable=no-member + ) + enrollment.update_enrollment(mode=mode) + self.assertTrue(mock_send_email.called) + + @patch.dict( + 'django.conf.settings.PROCTORING_BACKENDS', {'test_provider_honor_mode': {'allow_honor_mode': True}} + ) + 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. + """ + 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' + ) + CourseEnrollment.enroll(self.user, course_honor_mode.id, 'honor') # pylint: disable=no-member + self.assertTrue(mock_send_email.called) + @patch.dict(settings.FEATURES, {'EMBARGO': True}) def test_embargo_restrict(self): # When accessing the course from an embargoed country, diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index a5c58b81fb..b14feed4d7 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -52,7 +52,8 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_authn.utils import should_redirect_to_authn_microfrontend from openedx.core.djangolib.markup import HTML, Text -from common.djangoapps.student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info, generate_activation_email_context +from common.djangoapps.student.email_helpers import generate_activation_email_context +from common.djangoapps.student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info from common.djangoapps.student.message_types import AccountActivation, EmailChange, EmailChangeConfirmation, RecoveryEmailCreate from common.djangoapps.student.models import ( AccountRecovery, diff --git a/common/templates/student/edx_ace/proctoringrequirements/email/body.html b/common/templates/student/edx_ace/proctoringrequirements/email/body.html new file mode 100644 index 0000000000..eca71eb5d1 --- /dev/null +++ b/common/templates/student/edx_ace/proctoringrequirements/email/body.html @@ -0,0 +1,81 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load i18n %} +{% load static %} +{% block content %} +
+ + {% filter force_escape %} + {% blocktrans %} + Proctoring requirements for {{ course_name }} + {% endblocktrans %} + {% endfilter %} ++ ++ {% filter force_escape %} + {% blocktrans %} + Hello {{full_name}}, + {% endblocktrans %} + {% endfilter %} + + ++ {% filter force_escape %} + {% blocktrans %} + You are enrolled in {{ course_name }} at {{ platform_name }}. This course contains proctored exams. + {% endblocktrans %} + {% endfilter %} + + ++ {% filter force_escape %} + {% blocktrans %} + Proctored exams are timed exams that you take while proctoring software monitors your computer's desktop, webcam video, and audio. Your course uses {{ proctoring_provider }} software for proctoring. + {% endblocktrans %} + {% endfilter %} + + ++ {% filter force_escape %} + {% blocktrans %} + Carefully review the system requirements as well as the steps to take a proctored exam in order to ensure that you are prepared. + {% endblocktrans %} + {% endfilter %} + + + {% filter force_escape %} + {% blocktrans asvar course_cta_text %} + Proctoring Instructions and Requirements + {% endblocktrans %} + {% endfilter %} + {% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=proctoring_requirements_url %} + ++ {% filter force_escape %} + {% blocktrans %} + Before taking a graded proctored exam, you must have approved ID verification photos. + {% endblocktrans %} + {% endfilter %} + + {% filter force_escape %} + {% blocktrans %} + You can submit ID verification photos here. + {% endblocktrans %} + {% endfilter %} + + + +
+ {% trans "Thank you," as tmsg %}{{ tmsg | force_escape }}
+ |
+