Skip to content
Snippets Groups Projects
Unverified Commit bcc1ddd3 authored by Zachary Hancock's avatar Zachary Hancock Committed by GitHub
Browse files

add proctoring escalation contact setting (#24243)

parent af3691ed
Branches
Tags release-2020-06-24-17.42
No related merge requests found
...@@ -881,6 +881,32 @@ class CourseMetadataEditingTest(CourseTestCase): ...@@ -881,6 +881,32 @@ class CourseMetadataEditingTest(CourseTestCase):
test_model = CourseMetadata.fetch(self.fullcourse) test_model = CourseMetadata.fetch(self.fullcourse)
self.assertNotIn('giturl', test_model) self.assertNotIn('giturl', test_model)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'test_proctoring_provider',
'proctortrack': {}
},
)
def test_fetch_proctoring_escalation_email_present(self):
"""
If 'proctortrack' is an available provider, show the escalation email setting
"""
test_model = CourseMetadata.fetch(self.fullcourse)
self.assertIn('proctoring_escalation_email', test_model)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'test_proctoring_provider',
'alternate_provider': {}
},
)
def test_fetch_proctoring_escalation_email_not_present(self):
"""
If 'proctortrack' is not an available provider, don't show the escalation email setting
"""
test_model = CourseMetadata.fetch(self.fullcourse)
self.assertNotIn('proctoring_escalation_email', test_model)
@patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': False}) @patch.dict(settings.FEATURES, {'ENABLE_EXPORT_GIT': False})
def test_validate_update_filtered_off(self): def test_validate_update_filtered_off(self):
""" """
...@@ -1354,6 +1380,101 @@ class CourseMetadataEditingTest(CourseTestCase): ...@@ -1354,6 +1380,101 @@ class CourseMetadataEditingTest(CourseTestCase):
) )
self.assertNotIn(field_name, test_model) self.assertNotIn(field_name, test_model)
@ddt.data(True, False)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'test_proctoring_provider',
'test_proctoring_provider': {},
'proctortrack': {}
}
)
@override_waffle_flag(ENABLE_PROCTORING_PROVIDER_OVERRIDES, True)
def test_validate_update_requires_escalation_email_for_proctortrack(self, include_blank_email):
json_data = {
"proctoring_provider": {"value": 'proctortrack'},
}
if include_blank_email:
json_data["proctoring_escalation_email"] = {"value": ""}
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
json_data,
user=self.user
)
self.assertFalse(did_validate)
self.assertEqual(len(errors), 1)
self.assertIsNone(test_model)
self.assertEqual(
errors[0].get('message'),
'Provider \'proctortrack\' requires an exam escalation contact.'
)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'test_proctoring_provider',
'test_proctoring_provider': {},
'proctortrack': {}
}
)
@override_waffle_flag(ENABLE_PROCTORING_PROVIDER_OVERRIDES, True)
def test_validate_update_does_not_require_escalation_email_by_default(self):
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
{
"proctoring_provider": {"value": "test_proctoring_provider"},
},
user=self.user
)
self.assertTrue(did_validate)
self.assertEqual(len(errors), 0)
self.assertIn('proctoring_provider', test_model)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'proctortrack',
'proctortrack': {}
}
)
@override_waffle_flag(ENABLE_PROCTORING_PROVIDER_OVERRIDES, True)
def test_validate_update_cannot_unset_escalation_email_when_proctortrack_is_provider(self):
course = CourseFactory.create()
CourseMetadata.update_from_dict({"proctoring_provider": 'proctortrack'}, course, self.user)
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
course,
{
"proctoring_escalation_email": {"value": ""},
},
user=self.user
)
self.assertFalse(did_validate)
self.assertEqual(len(errors), 1)
self.assertIsNone(test_model)
self.assertEqual(
errors[0].get('message'),
'Provider \'proctortrack\' requires an exam escalation contact.'
)
@override_settings(
PROCTORING_BACKENDS={
'DEFAULT': 'proctortrack',
'proctortrack': {}
}
)
@override_waffle_flag(ENABLE_PROCTORING_PROVIDER_OVERRIDES, True)
def test_validate_update_set_proctortrack_provider_with_valid_escalation_email(self):
did_validate, errors, test_model = CourseMetadata.validate_and_update_from_json(
self.course,
{
"proctoring_provider": {"value": "proctortrack"},
"proctoring_escalation_email": {"value": "foo@bar.com"},
},
user=self.user
)
self.assertTrue(did_validate)
self.assertEqual(len(errors), 0)
self.assertIn('proctoring_provider', test_model)
self.assertIn('proctoring_escalation_email', test_model)
def test_create_zendesk_tickets_present_for_edx_staff(self): def test_create_zendesk_tickets_present_for_edx_staff(self):
""" """
Tests that create zendesk tickets field is not filtered out when the user is an edX staff member. Tests that create zendesk tickets field is not filtered out when the user is an edX staff member.
......
...@@ -6,6 +6,7 @@ Django module for Course Metadata class -- manages advanced settings and related ...@@ -6,6 +6,7 @@ Django module for Course Metadata class -- manages advanced settings and related
from datetime import datetime from datetime import datetime
import six import six
from crum import get_current_user from crum import get_current_user
from django.core.exceptions import ValidationError
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
import pytz import pytz
...@@ -143,6 +144,11 @@ class CourseMetadata(object): ...@@ -143,6 +144,11 @@ class CourseMetadata(object):
if not GlobalStaff().has_user(get_current_user()): if not GlobalStaff().has_user(get_current_user()):
exclude_list.append('create_zendesk_tickets') exclude_list.append('create_zendesk_tickets')
# Do not show "Proctortrack Exam Escalation Contact" if Proctortrack is not
# an available proctoring backend.
if not settings.PROCTORING_BACKENDS or settings.PROCTORING_BACKENDS.get('proctortrack') is None:
exclude_list.append('proctoring_escalation_email')
return exclude_list return exclude_list
@classmethod @classmethod
...@@ -244,16 +250,47 @@ class CourseMetadata(object): ...@@ -244,16 +250,47 @@ class CourseMetadata(object):
val = model['value'] val = model['value']
if hasattr(descriptor, key) and getattr(descriptor, key) != val: if hasattr(descriptor, key) and getattr(descriptor, key) != val:
key_values[key] = descriptor.fields[key].from_json(val) key_values[key] = descriptor.fields[key].from_json(val)
except (TypeError, ValueError) as err: except (TypeError, ValueError, ValidationError) as err:
did_validate = False did_validate = False
errors.append({'message': text_type(err), 'model': model}) errors.append({'message': text_type(err), 'model': model})
# Disallow updates to the proctoring provider after course start proctoring_errors = cls._validate_proctoring_settings(descriptor, filtered_dict, user)
proctoring_provider_model = filtered_dict.get('proctoring_provider', {}) if proctoring_errors:
errors = errors + proctoring_errors
did_validate = False
# If did validate, go ahead and update the metadata
if did_validate:
updated_data = cls.update_from_dict(key_values, descriptor, user, save=False)
return did_validate, errors, updated_data
@classmethod
def update_from_dict(cls, key_values, descriptor, user, save=True):
"""
Update metadata descriptor from key_values. Saves to modulestore if save is true.
"""
for key, value in six.iteritems(key_values):
setattr(descriptor, key, value)
if save and key_values:
modulestore().update_item(descriptor, user.id)
return cls.fetch(descriptor)
@classmethod
def _validate_proctoring_settings(cls, descriptor, settings_dict, user):
"""
Verify proctoring settings
Returns a list of error objects
"""
errors = []
# If the user is not edX staff, the user has requested a change to the proctoring_provider # If the user is not edX staff, the user has requested a change to the proctoring_provider
# Advanced Setting, and and it is after course start, prevent the user from changing the # Advanced Setting, and it is after course start, prevent the user from changing the
# proctoring provider. # proctoring provider.
proctoring_provider_model = settings_dict.get('proctoring_provider', {})
if ( if (
not user.is_staff and not user.is_staff and
cls._has_requested_proctoring_provider_changed( cls._has_requested_proctoring_provider_changed(
...@@ -261,25 +298,40 @@ class CourseMetadata(object): ...@@ -261,25 +298,40 @@ class CourseMetadata(object):
) and ) and
datetime.now(pytz.UTC) > descriptor.start datetime.now(pytz.UTC) > descriptor.start
): ):
did_validate = False
message = ( message = (
'The proctoring provider cannot be modified after a course has started.' 'The proctoring provider cannot be modified after a course has started.'
' Contact {support_email} for assistance' ' Contact {support_email} for assistance'
).format(support_email=settings.PARTNER_SUPPORT_EMAIL or 'support') ).format(support_email=settings.PARTNER_SUPPORT_EMAIL or 'support')
errors.append({'message': message, 'model': proctoring_provider_model}) errors.append({'message': message, 'model': proctoring_provider_model})
# If did validate, go ahead and update the metadata # Require a valid escalation email if Proctortrack is chosen as the proctoring provider
if did_validate: escalation_email_model = settings_dict.get('proctoring_escalation_email')
updated_data = cls.update_from_dict(key_values, descriptor, user, save=False) if escalation_email_model:
escalation_email = escalation_email_model.get('value')
else:
escalation_email = descriptor.proctoring_escalation_email
return did_validate, errors, updated_data missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.'
if proctoring_provider_model and proctoring_provider_model.get('value') == 'proctortrack':
if not escalation_email:
message = missing_escalation_email_msg.format(provider=proctoring_provider_model.get('value'))
errors.append({'message': message, 'model': proctoring_provider_model})
if (
escalation_email_model and not proctoring_provider_model and
descriptor.proctoring_provider == 'proctortrack'
):
if not escalation_email:
message = missing_escalation_email_msg.format(provider=descriptor.proctoring_provider)
errors.append({'message': message, 'model': escalation_email_model})
return errors
@staticmethod @staticmethod
def _has_requested_proctoring_provider_changed(current_provider, requested_provider): def _has_requested_proctoring_provider_changed(current_provider, requested_provider):
""" """
Return whether the requested proctoring provider is different than the current proctoring provider, indicating Return whether the requested proctoring provider is different than the current proctoring provider, indicating
that the user has requested a change to the proctoring_provider Advanced Setting. that the user has requested a change to the proctoring_provider Advanced Setting.
The requested_provider will be None if the proctoring_provider setting is not available (e.g. if the The requested_provider will be None if the proctoring_provider setting is not available (e.g. if the
ENABLE_PROCTORING_PROVIDER_OVERRIDES waffle flag is not enabled for the course). In this case, we consider ENABLE_PROCTORING_PROVIDER_OVERRIDES waffle flag is not enabled for the course). In this case, we consider
that there is no change in the requested proctoring provider. that there is no change in the requested proctoring provider.
...@@ -288,16 +340,3 @@ class CourseMetadata(object): ...@@ -288,16 +340,3 @@ class CourseMetadata(object):
return False return False
else: else:
return current_provider != requested_provider return current_provider != requested_provider
@classmethod
def update_from_dict(cls, key_values, descriptor, user, save=True):
"""
Update metadata descriptor from key_values. Saves to modulestore if save is true.
"""
for key, value in six.iteritems(key_values):
setattr(descriptor, key, value)
if save and key_values:
modulestore().update_item(descriptor, user.id)
return cls.fetch(descriptor)
...@@ -12,6 +12,7 @@ import dateutil.parser ...@@ -12,6 +12,7 @@ import dateutil.parser
import requests import requests
import six import six
from django.conf import settings from django.conf import settings
from django.core.validators import validate_email
from lazy import lazy from lazy import lazy
from lxml import etree from lxml import etree
from path import Path as path from path import Path as path
...@@ -83,6 +84,18 @@ class StringOrDate(Date): ...@@ -83,6 +84,18 @@ class StringOrDate(Date):
return result return result
class EmailString(String):
"""
Parse String with email validation
"""
def from_json(self, value):
if value:
validate_email(value)
return value
else:
return None
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True) remove_comments=True, remove_blank_text=True)
...@@ -876,6 +889,17 @@ class CourseFields(object): ...@@ -876,6 +889,17 @@ class CourseFields(object):
scope=Scope.settings, scope=Scope.settings,
) )
proctoring_escalation_email = EmailString(
display_name=_("Proctortrack Exam Escalation Contact"),
help=_(
"Required if 'proctortrack' is selected as your proctoring provider. "
"Enter an email address to be contacted by the support team whenever there are escalations "
"(e.g. appeals, delayed reviews, etc.)."
),
default=None,
scope=Scope.settings
)
allow_proctoring_opt_out = Boolean( allow_proctoring_opt_out = Boolean(
display_name=_("Allow Opting Out of Proctored Exams"), display_name=_("Allow Opting Out of Proctored Exams"),
help=_( help=_(
......
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment