Skip to content

Commit

Permalink
feat: add escalation_email to course exam configurations
Browse files Browse the repository at this point in the history
This commit adds an escalation email to course exam configurations. This value is used to specify who learners can contact in the event of issues with or questions about their exam attempts.
  • Loading branch information
MichaelRoytman committed Dec 6, 2023
1 parent 8d9369f commit fe4505a
Show file tree
Hide file tree
Showing 13 changed files with 491 additions and 82 deletions.
56 changes: 55 additions & 1 deletion edx_exams/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
from edx_exams.apps.api.constants import ASSESSMENT_CONTROL_CODES
from edx_exams.apps.core.api import get_exam_attempt_time_remaining, get_exam_url_path
from edx_exams.apps.core.exam_types import EXAM_TYPES
from edx_exams.apps.core.models import AssessmentControlResult, Exam, ExamAttempt, ProctoringProvider, User
from edx_exams.apps.core.models import (
AssessmentControlResult,
CourseExamConfiguration,
Exam,
ExamAttempt,
ProctoringProvider,
User
)


class UserSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -40,6 +47,53 @@ class Meta:
fields = ['name', 'verbose_name', 'lti_configuration_id']


class CourseExamConfigurationSerializer(serializers.ModelSerializer):
"""
Serializer for the CourseExamConfiguration model
"""
provider = serializers.CharField(source='provider.name', allow_null=True)

# The escalation_email is only required when the provider is not None.
# We enforce this constraint in the validate function.
escalation_email = serializers.EmailField(required=False, default='')

class Meta:
model = CourseExamConfiguration
fields = ['provider', 'escalation_email']

def validate_provider(self, value):
"""
Validate that the provider value corresponds to an existing ProctoringProvider. If the value is None,
the value is valid.
"""
if value is not None:
try:
ProctoringProvider.objects.get(name=value)
# This exception is handled by the Django Rest Framework, so we don't want to use raise from and risk
# getting the stack trace included in client facing errors.
except ProctoringProvider.DoesNotExist:
raise serializers.ValidationError( # pylint: disable=raise-missing-from
'Proctoring provider does not exist.'
)
return value

return value

def validate(self, attrs):
"""
Validate that escalalation_email is provided when the provider is provided.
NOTE: Currently, the LTI-based proctoring providers will require an escalation_email. Because the
escalation_email field was added to the model later, a default of the empty string was required. For
this reason, DRF generates an escalation_email serializer field that is optional. The current requirement is
that escalation_email is required unless the provider is None. Exceptions to this requirement can be added to
this function in the future.
"""
if attrs.get('provider') and attrs.get('provider').get('name') and not attrs.get('escalation_email'):
raise serializers.ValidationError('Escalation email is a required field when provider is provided.')
return attrs


class ExamSerializer(serializers.ModelSerializer):
"""
Serializer for the Exam Model
Expand Down
36 changes: 28 additions & 8 deletions edx_exams/apps/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ def test_course_staff_write_access(self):
})
self.assertEqual(204, response.status_code)

def test_patch_invalid_data(self):
def test_patch_invalid_data_no_provider(self):
"""
Assert that endpoint returns 400 if provider is missing
"""
Expand All @@ -370,11 +370,21 @@ def test_patch_invalid_data(self):
response = self.patch_api(self.user, data)
self.assertEqual(400, response.status_code)

def test_patch_invalid_provider(self):
def test_patch_invalid_data_no_escalation_email(self):
"""
Assert that endpoint returns 400 if provider is provided but escalation_email is missing
"""
data = {'provider': 'test_provider'}

response = self.patch_api(self.user, data)
self.assertEqual(400, response.status_code)

@ddt.data('nonexistent_provider', '')
def test_patch_invalid_provider(self, provider_name):
"""
Assert endpoint returns 400 if provider is invalid
"""
data = {'provider': 'nonexistent_provider'}
data = {'provider': provider_name}

response = self.patch_api(self.user, data)
self.assertEqual(400, response.status_code)
Expand All @@ -392,14 +402,17 @@ def test_patch_config_update(self):
verbose_name='testing_provider_2',
lti_configuration_id='223456789'
)
data = {'provider': provider.name}
escalation_email = '[email protected]'

data = {'provider': provider.name, 'escalation_email': escalation_email}

response = self.patch_api(self.user, data)
self.assertEqual(204, response.status_code)
self.assertEqual(len(CourseExamConfiguration.objects.all()), 1)

config = CourseExamConfiguration.get_configuration_for_course(self.course_id)
self.assertEqual(config.provider, provider)
self.assertEqual(config.escalation_email, escalation_email)

def test_patch_config_update_exams(self):
"""
Expand Down Expand Up @@ -441,12 +454,15 @@ def test_patch_config_update_exams(self):
exams = Exam.objects.filter(course_id=self.course_id, is_active=True)
self.assertEqual(2, len(exams))

data = {'provider': provider.name}
escalation_email = '[email protected]'

data = {'provider': provider.name, 'escalation_email': escalation_email}
response = self.patch_api(self.user, data)
self.assertEqual(204, response.status_code)
self.assertEqual(len(CourseExamConfiguration.objects.all()), 1)
config = CourseExamConfiguration.get_configuration_for_course(self.course_id)
self.assertEqual(config.provider, provider)
self.assertEqual(config.escalation_email, escalation_email)

exams = Exam.objects.filter(course_id=self.course_id, is_active=True)
self.assertEqual(2, len(exams))
Expand All @@ -459,12 +475,13 @@ def test_patch_config_update_exams(self):
self.assertEqual(self.test_provider, exam.provider)

# updating to the same provider is a do nothing, no new exams
data = {'provider': provider.name}
data = {'provider': provider.name, 'escalation_email': escalation_email}
response = self.patch_api(self.user, data)
self.assertEqual(204, response.status_code)
self.assertEqual(len(CourseExamConfiguration.objects.all()), 1)
config = CourseExamConfiguration.get_configuration_for_course(self.course_id)
self.assertEqual(config.provider, provider)
self.assertEqual(config.escalation_email, escalation_email)

exams = Exam.objects.filter(course_id=self.course_id, is_active=True)
self.assertEqual(2, len(exams))
Expand All @@ -477,12 +494,13 @@ def test_patch_config_update_exams(self):
self.assertEqual(self.test_provider, exam.provider)

# updating back to the original provider creates two new active exams, now 4 inactive
data = {'provider': self.test_provider.name}
data = {'provider': self.test_provider.name, 'escalation_email': '[email protected]'}
response = self.patch_api(self.user, data)
self.assertEqual(204, response.status_code)
self.assertEqual(len(CourseExamConfiguration.objects.all()), 1)
config = CourseExamConfiguration.get_configuration_for_course(self.course_id)
self.assertEqual(config.provider, self.test_provider)
self.assertEqual(config.escalation_email, escalation_email)

exams = Exam.objects.filter(course_id=self.course_id, is_active=True)
self.assertEqual(2, len(exams))
Expand All @@ -496,14 +514,16 @@ def test_patch_config_create(self):
"""
Test that config is created
"""
data = {'provider': 'test_provider'}
escalation_email = '[email protected]'
data = {'provider': 'test_provider', 'escalation_email': escalation_email}

response = self.patch_api(self.user, data)
self.assertEqual(204, response.status_code)
self.assertEqual(len(CourseExamConfiguration.objects.all()), 1)

config = CourseExamConfiguration.get_configuration_for_course(self.course_id)
self.assertEqual(config.provider, self.test_provider)
self.assertEqual(config.escalation_email, escalation_email)

def test_patch_null_provider(self):
"""
Expand Down
56 changes: 25 additions & 31 deletions edx_exams/apps/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from edx_exams.apps.api.permissions import CourseStaffOrReadOnlyPermissions, CourseStaffUserPermissions
from edx_exams.apps.api.serializers import (
CourseExamConfigurationSerializer,
ExamSerializer,
InstructorViewAttemptSerializer,
ProctoringProviderSerializer,
Expand All @@ -26,6 +27,7 @@
from edx_exams.apps.core.api import (
check_if_exam_timed_out,
create_exam_attempt,
create_or_update_course_exam_configuration,
get_active_attempt_for_user,
get_attempt_by_id,
get_course_exams,
Expand Down Expand Up @@ -227,15 +229,20 @@ class CourseExamConfigurationsView(ExamsAPIView):
**Returns**
{
'provider': 'test_provider',
'escalation_email': '[email protected]',
}
HTTP PATCH
Creates or updates a CourseExamConfiguration.
Expected PATCH data: {
'provider': 'test_provider',
'escalation_email': '[email protected]',
}
**PATCH data Parameters**
* name: This is the name of the proctoring provider.
* provider: This is the name of the selected proctoring provider for the course.
* escalation_email: This is the email to which learners should send emails to escalate problems for the course.
This parameter is only required if the provider is not None.
**Exceptions**
* HTTP_400_BAD_REQUEST
"""
Expand All @@ -246,46 +253,33 @@ class CourseExamConfigurationsView(ExamsAPIView):
def get(self, request, course_id):
"""
Get exam configuration for a course
TODO: This view should use a serializer to ensure the read/write bodies are the same
once more fields are added.
"""
try:
provider = CourseExamConfiguration.objects.get(course_id=course_id).provider
except ObjectDoesNotExist:
provider = None
configuration = CourseExamConfiguration.objects.get(course_id=course_id)
except CourseExamConfiguration.DoesNotExist:
# If configuration is set to None, then the provider is serialized to the empty string instead of None.
configuration = {}

return Response({
'provider': provider.name if provider else None
})
serializer = CourseExamConfigurationSerializer(configuration)
return Response(serializer.data)

def patch(self, request, course_id):
"""
Create/update course exam configuration.
"""
error = None
serializer = CourseExamConfigurationSerializer(data=request.data)

# check that proctoring provider is in request
if 'provider' not in request.data:
error = {'detail': 'No proctoring provider name in request.'}
elif request.data.get('provider') is None:
provider = None
else:
try:
provider = ProctoringProvider.objects.get(name=request.data['provider'])
# return 400 if proctoring provider does not exist
except ObjectDoesNotExist:
error = {'detail': 'Proctoring provider does not exist.'}

if not error:
CourseExamConfiguration.create_or_update(provider, course_id)
response_status = status.HTTP_204_NO_CONTENT
data = {}
if serializer.is_valid():
validated_data = serializer.validated_data
create_or_update_course_exam_configuration(
course_id,
validated_data['provider']['name'],
# Escalation email may be optional; use None if it's not provided.
validated_data.get('escalation_email')
)
return Response({}, status=status.HTTP_204_NO_CONTENT)
else:
response_status = status.HTTP_400_BAD_REQUEST
data = error

return Response(status=response_status, data=data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class ProctoringProvidersView(ListAPIView):
Expand Down
6 changes: 3 additions & 3 deletions edx_exams/apps/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ def change_view(self, request, object_id, form_url='', extra_context=None):
@admin.register(CourseExamConfiguration)
class CourseExamConfigurationAdmin(admin.ModelAdmin):
""" Admin configuration for the Course Exam Configuration model """
list_display = ('course_id', 'provider', 'allow_opt_out')
readonly_fields = ('course_id', 'provider')
search_fields = ('course_id', 'provider__name', 'allow_opt_out')
list_display = ('course_id', 'provider', 'allow_opt_out', 'escalation_email')
readonly_fields = ('course_id', 'provider', 'escalation_email')
search_fields = ('course_id', 'provider__name', 'allow_opt_out', 'escalation_email')
ordering = ('course_id',)


Expand Down
45 changes: 43 additions & 2 deletions edx_exams/apps/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ExamDoesNotExist,
ExamIllegalStatusTransition
)
from edx_exams.apps.core.models import Exam, ExamAttempt
from edx_exams.apps.core.models import CourseExamConfiguration, Exam, ExamAttempt, ProctoringProvider
from edx_exams.apps.core.signals.signals import (
emit_exam_attempt_errored_event,
emit_exam_attempt_rejected_event,
Expand Down Expand Up @@ -141,7 +141,8 @@ def update_attempt_status(attempt_id, to_status):
attempt_obj.status = to_status
attempt_obj.save()

send_attempt_status_email(attempt_obj)
escalation_email = get_escalation_email(exam_id)
send_attempt_status_email(attempt_obj, escalation_email)

return attempt_id

Expand Down Expand Up @@ -422,3 +423,43 @@ def is_exam_passed_due(exam):
due_date = dateparse.parse_datetime(due_date)
return due_date <= datetime.now(pytz.UTC)
return False


def get_escalation_email(exam_id):
"""
Return contact details for the course exam configuration. These details describe who learners should reach out to
for support with proctored exams.
Parameters:
* exam_id: the ID representing the exam
Returns:
* escalation_email: the escalation_email registered to the course in which the exam is configured, if there is
one; else, None.
"""
exam_obj = Exam.get_exam_by_id(exam_id)

try:
course_config = CourseExamConfiguration.objects.get(course_id=exam_obj.course_id)
except CourseExamConfiguration.DoesNotExist:
return None
else:
return course_config.escalation_email


def create_or_update_course_exam_configuration(course_id, provider_name, escalation_email):
"""
Create or update the exam configuration for a course specified by course_id. If the course exam configuration
does not yet exist, create one with the provider set to the provider associated with the provider_name and the
escalation_email set to the escalation_email.
Parameters:
* course_id: the ID representing the course
* provider_name: the name of the proctoring provider
* escalation_email: the escalation email
"""
provider = provider_name
if provider_name is not None:
provider = ProctoringProvider.objects.get(name=provider_name)

CourseExamConfiguration.create_or_update(course_id, provider, escalation_email)
12 changes: 10 additions & 2 deletions edx_exams/apps/core/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
log = logging.getLogger(__name__)


def send_attempt_status_email(attempt):
def send_attempt_status_email(attempt, escalation_email=None):
"""
Send email for attempt status if necessary
"""
Expand All @@ -35,13 +35,21 @@ def send_attempt_status_email(attempt):

email_template = loader.get_template(email_template)
course_url = f'{settings.LEARNING_MICROFRONTEND_URL}/course/{exam.course_id}'
contact_url = f'{settings.LMS_ROOT_URL}/support/contact_us'

# If the course has a proctoring escalation email set, then use that rather than edX Support.
if escalation_email:
contact_url = f'mailto:{escalation_email}'
contact_url_text = escalation_email
else:
contact_url = f'{settings.LMS_ROOT_URL}/support/contact_us'
contact_url_text = contact_url

email_subject = f'Proctored exam {exam.exam_name} for user {attempt.user.username}'
body = email_template.render({
'exam_name': exam.exam_name,
'course_url': course_url,
'contact_url': contact_url,
'contact_url_text': contact_url_text,
})

email = EmailMessage(
Expand Down
Loading

0 comments on commit fe4505a

Please sign in to comment.