Merge pull request #26182 from edx/bseverino/proctoring-requirements-email
[MST-515] Automated proctoring requirements email
This commit is contained in:
53
common/djangoapps/student/email_helpers.py
Normal file
53
common/djangoapps/student/email_helpers.py
Normal file
@@ -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(),
|
||||
}
|
||||
34
common/djangoapps/student/emails.py
Normal file
34
common/djangoapps/student/emails.py
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_body.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<h1>
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
Proctoring requirements for {{ course_name }}
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</h1>
|
||||
|
||||
<p style="color: rgba(0,0,0,.75);">
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
Hello {{full_name}},
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</p>
|
||||
|
||||
<p style="color: rgba(0,0,0,.75);">
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
You are enrolled in {{ course_name }} at {{ platform_name }}. This course contains proctored exams.
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</p>
|
||||
|
||||
<p style="color: rgba(0,0,0,.75);">
|
||||
{% 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 %}
|
||||
</p>
|
||||
|
||||
<p style="color: rgba(0,0,0,.75);">
|
||||
{% 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 %}
|
||||
</p>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<p style="color: rgba(0,0,0,.75);">
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
Before taking a graded proctored exam, you must have approved ID verification photos.
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
<a href="{{ id_verification_url }}">
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
You can submit ID verification photos here.
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{% trans "Thank you," as tmsg %}{{ tmsg | force_escape }}
|
||||
<br />
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}The {{ platform_name }} Team {% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1 @@
|
||||
{{ platform_name }}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_head.html' %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans %}Proctoring requirements for {{ course_name }}{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 #####################
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user