From 902ab023572e2319b0e596c289703abcf16a24e6 Mon Sep 17 00:00:00 2001 From: Bianca Severino Date: Mon, 25 Jan 2021 14:54:13 -0500 Subject: [PATCH] Send proctoring requirements email when enrolled in relevant course mode --- common/djangoapps/student/email_helpers.py | 53 ++++++++++++ common/djangoapps/student/emails.py | 34 ++++++++ common/djangoapps/student/helpers.py | 43 +++------- common/djangoapps/student/message_types.py | 17 ++-- common/djangoapps/student/models.py | 24 ++++-- common/djangoapps/student/tests/test_email.py | 86 +++++++++++++++++-- .../student/tests/test_enrollment.py | 67 +++++++++++++++ common/djangoapps/student/views/management.py | 3 +- .../proctoringrequirements/email/body.html | 81 +++++++++++++++++ .../proctoringrequirements/email/body.txt | 17 ++++ .../email/from_name.txt | 1 + .../proctoringrequirements/email/head.html | 1 + .../proctoringrequirements/email/subject.txt | 4 + lms/djangoapps/courseware/rules.py | 12 +-- .../courseware/tests/test_module_render.py | 2 +- lms/djangoapps/courseware/tests/test_rules.py | 51 ++++++----- lms/envs/test.py | 6 +- openedx/core/djangoapps/enrollments/api.py | 36 ++++++++ 18 files changed, 448 insertions(+), 90 deletions(-) create mode 100644 common/djangoapps/student/email_helpers.py create mode 100644 common/djangoapps/student/emails.py create mode 100644 common/templates/student/edx_ace/proctoringrequirements/email/body.html create mode 100644 common/templates/student/edx_ace/proctoringrequirements/email/body.txt create mode 100644 common/templates/student/edx_ace/proctoringrequirements/email/from_name.txt create mode 100644 common/templates/student/edx_ace/proctoringrequirements/email/head.html create mode 100644 common/templates/student/edx_ace/proctoringrequirements/email/subject.txt 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 d69a4a268a..342efbe162 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_logistration_mircrofrontend 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 }} +
+ {% filter force_escape %} + {% blocktrans %}The {{ platform_name }} Team {% endblocktrans %} + {% endfilter %} +

+
+{% endblock %} diff --git a/common/templates/student/edx_ace/proctoringrequirements/email/body.txt b/common/templates/student/edx_ace/proctoringrequirements/email/body.txt new file mode 100644 index 0000000000..830a1d89af --- /dev/null +++ b/common/templates/student/edx_ace/proctoringrequirements/email/body.txt @@ -0,0 +1,17 @@ +{% load i18n %}{% autoescape off %}{% blocktrans %}Hello {{ full_name }}{% endblocktrans %} + +{% blocktrans %}You are enrolled in {{ course_name }} at {{ platform_name }}. This course contains proctored exams.{% endblocktrans %} + +{% 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 %} + +{% 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 %} + +{% blocktrans %}To view proctoring instructions and requirements, please visit: {{ proctoring_requirements_url }}{% endblocktrans %} + +{% blocktrans %}Before taking a graded proctored exam, you must have approved ID verification photos. You can submit ID verification photos here: {{ id_verification_url }}{% endblocktrans %} + +{% trans "Thank you," %} +{% blocktrans %}The {{ platform_name }} Team {% endblocktrans %} + +{% blocktrans %}This email was automatically sent from {{ platform_name }} to {{ full_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/common/templates/student/edx_ace/proctoringrequirements/email/from_name.txt b/common/templates/student/edx_ace/proctoringrequirements/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/common/templates/student/edx_ace/proctoringrequirements/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/common/templates/student/edx_ace/proctoringrequirements/email/head.html b/common/templates/student/edx_ace/proctoringrequirements/email/head.html new file mode 100644 index 0000000000..366ada7ad9 --- /dev/null +++ b/common/templates/student/edx_ace/proctoringrequirements/email/head.html @@ -0,0 +1 @@ +{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/common/templates/student/edx_ace/proctoringrequirements/email/subject.txt b/common/templates/student/edx_ace/proctoringrequirements/email/subject.txt new file mode 100644 index 0000000000..af644f9362 --- /dev/null +++ b/common/templates/student/edx_ace/proctoringrequirements/email/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Proctoring requirements for {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/djangoapps/courseware/rules.py b/lms/djangoapps/courseware/rules.py index 2e54ef771b..30db5d6a5c 100644 --- a/lms/djangoapps/courseware/rules.py +++ b/lms/djangoapps/courseware/rules.py @@ -16,9 +16,9 @@ from opaque_keys.edx.django.models import CourseKeyField from opaque_keys.edx.keys import CourseKey, UsageKey from xblock.core import XBlock -from common.djangoapps.course_modes.models import CourseMode from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment +from openedx.core.djangoapps.enrollments.api import is_enrollment_valid_for_proctoring +from common.djangoapps.student.models import CourseAccessRole from common.djangoapps.student.roles import CourseRole, OrgRole from xmodule.course_module import CourseDescriptor from xmodule.error_module import ErrorBlock @@ -36,13 +36,7 @@ def is_track_ok_for_exam(user, exam): Returns whether the user is in an appropriate enrollment mode """ course_id = CourseKey.from_string(exam['course_id']) - mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_id) - appropriate_modes = [ - CourseMode.VERIFIED, CourseMode.MASTERS, CourseMode.PROFESSIONAL, CourseMode.EXECUTIVE_EDUCATION - ] - if exam.get('is_proctored') and settings.PROCTORING_BACKENDS.get(exam['backend'], {}).get('allow_honor_mode'): - appropriate_modes.append(CourseMode.HONOR) - return is_active and mode in appropriate_modes + return is_enrollment_valid_for_proctoring(user.username, course_id) # The edx_proctoring.api uses this permission to gate access to the diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 5e0acdc1fe..21fe645557 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1110,7 +1110,7 @@ class TestProctoringRendering(SharedModuleStoreTestCase): @classmethod def setUpClass(cls): super(TestProctoringRendering, cls).setUpClass() - cls.course_key = ToyCourseFactory.create().id + cls.course_key = ToyCourseFactory.create(enable_proctored_exams=True).id def setUp(self): """ diff --git a/lms/djangoapps/courseware/tests/test_rules.py b/lms/djangoapps/courseware/tests/test_rules.py index ac54e2c89a..21c25c0ff6 100644 --- a/lms/djangoapps/courseware/tests/test_rules.py +++ b/lms/djangoapps/courseware/tests/test_rules.py @@ -4,37 +4,37 @@ Tests for permissions defined in courseware.rules import ddt -import six -from django.test import TestCase -from django.test.utils import override_settings -from opaque_keys.edx.locator import CourseLocator +from mock import patch from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory @ddt.ddt -class PermissionTests(TestCase): +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) +class PermissionTests(ModuleStoreTestCase): """ Tests for permissions defined in courseware.rules """ def setUp(self): - super(PermissionTests, self).setUp() + super().setUp() self.user = UserFactory() + self.course = CourseFactory(enable_proctored_exams=True) - self.course_id = CourseLocator('MITx', '000', 'Perm_course') + self.course_id = self.course.id # pylint: disable=no-member CourseModeFactory(mode_slug='verified', course_id=self.course_id) CourseModeFactory(mode_slug='masters', course_id=self.course_id) CourseModeFactory(mode_slug='professional', course_id=self.course_id) CourseEnrollment.unenroll(self.user, self.course_id) def tearDown(self): - super(PermissionTests, self).tearDown() + super().tearDown() self.user.delete() @ddt.data( - (None, False), ('audit', False), ('verified', True), ('masters', True), @@ -48,29 +48,28 @@ class PermissionTests(TestCase): """ if mode is not None: CourseEnrollment.enroll(self.user, self.course_id, mode=mode) - has_perm = self.user.has_perm('edx_proctoring.can_take_proctored_exam', - {'course_id': six.text_type(self.course_id)}) + has_perm = self.user.has_perm( + 'edx_proctoring.can_take_proctored_exam', {'course_id': str(self.course_id)} + ) assert has_perm == should_have_perm - @override_settings( - PROCTORING_BACKENDS={ - 'mock_proctoring_allow_honor_mode': { - 'allow_honor_mode': True, - }, - } + @patch.dict( + 'django.conf.settings.PROCTORING_BACKENDS', + {'mock_proctoring_allow_honor_mode': {'allow_honor_mode': True}} ) def test_proctoring_perm_with_honor_mode_permission(self): """ Test that the user has the edx_proctoring.can_take_proctored_exam permission in honor enrollment mode. If proctoring backend configuration allows exam in honor mode {`allow_honor_mode`: True} the user is - granter proctored exam permission. + granted proctored exam permission. """ - CourseEnrollment.enroll(self.user, self.course_id, mode='honor') - self.assertTrue(self.user.has_perm( - 'edx_proctoring.can_take_proctored_exam', { - 'course_id': six.text_type(self.course_id), - 'backend': 'mock_proctoring_allow_honor_mode', - 'is_proctored': True - } - )) + course_allow_honor = CourseFactory( + enable_proctored_exams=True, proctoring_provider='mock_proctoring_allow_honor_mode' + ) + CourseEnrollment.enroll(self.user, course_allow_honor.id, mode='honor') + self.assertTrue( + self.user.has_perm( + 'edx_proctoring.can_take_proctored_exam', {'course_id': str(course_allow_honor.id)} + ) + ) diff --git a/lms/envs/test.py b/lms/envs/test.py index c22875dc02..2e7b43645c 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -577,7 +577,11 @@ LEARNING_MICROFRONTEND_URL = "http://learning-mfe" DASHBOARD_COURSE_LIMIT = 250 -PROCTORING_SETTINGS = {} +PROCTORING_SETTINGS = { + 'LINK_URLS': { + 'faq': 'https://support.example.com/proctoring-faq.html' + } +} ############### Settings for Django Rate limit ##################### diff --git a/openedx/core/djangoapps/enrollments/api.py b/openedx/core/djangoapps/enrollments/api.py index a71aeed556..8d7647a893 100644 --- a/openedx/core/djangoapps/enrollments/api.py +++ b/openedx/core/djangoapps/enrollments/api.py @@ -14,6 +14,7 @@ from opaque_keys.edx.keys import CourseKey from common.djangoapps.course_modes.models import CourseMode from openedx.core.djangoapps.enrollments import errors +from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) @@ -494,6 +495,41 @@ def serialize_enrollments(enrollments): return _data_api().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. + + Arguments: + username: 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 + + enrollment = _data_api().get_course_enrollment(username, str(course_id)) + if not enrollment['is_active']: + return False + + course_module = modulestore().get_course(course_id) + if not course_module.enable_proctored_exams: + return False + + 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 + if settings.PROCTORING_BACKENDS.get(course_module.proctoring_provider, {}).get('allow_honor_mode'): + appropriate_modes.append(CourseMode.HONOR) + + if enrollment['mode'] not in appropriate_modes: + return False + + return True + + def _data_api(): """Returns a Data API. This relies on Django settings to find the appropriate data API.