Merge pull request #26182 from edx/bseverino/proctoring-requirements-email

[MST-515] Automated proctoring requirements email
This commit is contained in:
Bianca Severino
2021-02-01 10:19:56 -05:00
committed by GitHub
18 changed files with 448 additions and 90 deletions

View 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(),
}

View 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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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):
"""

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1 @@
{{ platform_name }}

View File

@@ -0,0 +1 @@
{% extends 'ace_common/edx_ace/common/base_head.html' %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans %}Proctoring requirements for {{ course_name }}{% endblocktrans %}
{% endautoescape %}

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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)}
)
)

View File

@@ -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 #####################

View File

@@ -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.