Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add escalation_email to course exam configurations #221

Merged
merged 1 commit into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 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,67 @@ class Meta:
fields = ['name', 'verbose_name', 'lti_configuration_id']


class CourseExamConfigurationWriteSerializer(serializers.ModelSerializer):
"""
Serializer for writing to the CourseExamConfiguration model.

We have separate read and write serializers because the read serializer uses the name field of the provider instance
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on an earlier conversation with Zach, I decided it would be better to have two separate serializers than to have to worry about someone reading the data from the serializer improperly. This will be less error prone, and it's explicit that there are two different serializers - one for reading and one for writing. I can revert this to the old code (see my first commit), but I think this is an improvement.

to populate the provider serializer field by using the source attribute. The write serializer, on the other hand,
accepts the name as the value for the provider field. If we use the same serializer, deserialized data gets
represented as {'provider': {'name': <provider>}}, which makes reading the data less clear.
"""
provider = serializers.CharField(allow_null=True)
# The escalation_email is a nullable field, but we require that clients send us an escalation_email.
escalation_email = serializers.EmailField(allow_null=True, allow_blank=False, required=True)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a change.

The original was escalation_email = EmailField(allow_blank=True, max_length=254, required=False), which is the default generated by DRF.

The new one is escalation_email = serializers.EmailField(allow_null=True, allow_blank=False, required=True).

The two differences are...

  1. We now allow nulls and forbid blanks. Although this is not Django/DRF best-practice for character based fields, this more closely matches both the existing API on this endpoint, because provider is a nullable character field, and the Studio proctoring settings API, which accepts a null for the escalation_email. I have set allow_blank=False so that we only have one representation of the empty value.
  2. This field is now required. I think it's a clearer API contract with the client for this value to always be required. Otherwise, the client is left wondering why a value is required sometimes and not other times. Sending escalation_email: null from the client is more explicit.


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 e" and risk
# getting the stack trace included in client facing errors.
except ProctoringProvider.DoesNotExist:
raise serializers.ValidationError('Proctoring provider does not exist.') from None
return value

return value

def validate(self, attrs):
"""
Validate that escalalation_email is None if the provider is None.
"""
if attrs.get('provider') is None and attrs.get('escalation_email') is not None:
raise serializers.ValidationError('Escalation email must be None when provider is None.')
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new validation error. In the spirit of being more explicit, I've removed the behavior I had originally, which was to set the escalation_email to None when the provider is None, even when the escalation_email is not None (e.g. {'provider': None, 'escalation_email': '[email protected]'}). The client should supply valid data or receive a 400 error. I don't want to get in the habit of guessing what the client's intentions are when the escalation is not valid.


return attrs


class CourseExamConfigurationReadSerializer(serializers.ModelSerializer):
"""
Serializer for reading from the CourseExamConfiguration model

We have separate read and write serializers because the read serializer uses the name field of the provider instance
to populate the provider serializer field by using the source attribute. The write serializer, on the other hand,
accepts the name as the value for the provider field. If we use the same serializer, deserialized data gets
represented as {'provider': {'name': <provider>}}, which makes reading the data less clear.
"""
provider = serializers.CharField(source='provider.name', allow_null=True)
# The escalation_email is a nullable field, but we require that clients send us an escalation_email.
escalation_email = serializers.EmailField(allow_null=True, allow_blank=False, required=True)

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


class ExamSerializer(serializers.ModelSerializer):
"""
Serializer for the Exam Model
Expand Down
56 changes: 45 additions & 11 deletions edx_exams/apps/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,23 +358,46 @@ def test_course_staff_write_access(self):
CourseStaffRole.objects.create(user=course_staff_user, course_id=self.course_id)
response = self.patch_api(course_staff_user, {
'provider': None,
'escalation_email': None,
})
self.assertEqual(204, response.status_code)

def test_patch_invalid_data(self):
@ddt.data(
{},
{'escalation_email': '[email protected]'},
{'provider': 'test-provider'}
)
def test_patch_invalid_data_missing_data(self, data):
"""
Assert that endpoint returns 400 if any required parameter is missing
"""
response = self.patch_api(self.user, data)
self.assertEqual(400, response.status_code)

def test_patch_invalid_data_escalation_email(self):
"""
Assert that endpoint returns 400 if provider is missing
Assert that endpoint returns 400 if provider is None but escalation_email is not
"""
data = {}
data = {'provider': None, 'escalation_email': '[email protected]'}

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

def test_patch_invalid_provider(self):
def test_patch_invalid_data_invalid_escalation_email(self):
"""
Assert that endpoint returns 400 if the escalation email is not a valid email.
"""
data = {'provider': 'test_provider', 'escalation_email': 'test'}

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 +415,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 +467,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 +488,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 +507,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,27 +527,30 @@ 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):
"""
Assert provider can be explicitly set to null
"""
data = {'provider': None}
data = {'provider': None, 'escalation_email': None}

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, None)
self.assertEqual(config.escalation_email, None)

def test_get_config(self):
"""
Expand Down
55 changes: 24 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,8 @@

from edx_exams.apps.api.permissions import CourseStaffOrReadOnlyPermissions, CourseStaffUserPermissions
from edx_exams.apps.api.serializers import (
CourseExamConfigurationReadSerializer,
CourseExamConfigurationWriteSerializer,
ExamSerializer,
InstructorViewAttemptSerializer,
ProctoringProviderSerializer,
Expand All @@ -26,6 +28,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 +230,19 @@ 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.

**Exceptions**
* HTTP_400_BAD_REQUEST
"""
Expand All @@ -246,46 +253,32 @@ 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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a serializer, so I've removed this comment.

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 = CourseExamConfigurationReadSerializer(configuration)
return Response(serializer.data)

def patch(self, request, course_id):
"""
Create/update course exam configuration.
"""
error = None
serializer = CourseExamConfigurationWriteSerializer(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'],
validated_data['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)
MichaelRoytman marked this conversation as resolved.
Show resolved Hide resolved


class ProctoringProvidersView(ListAPIView):
Expand Down
4 changes: 2 additions & 2 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')
list_display = ('course_id', 'provider', 'allow_opt_out', 'escalation_email')
readonly_fields = ('course_id', 'provider')
search_fields = ('course_id', 'provider__name', 'allow_opt_out')
search_fields = ('course_id', 'provider__name', 'allow_opt_out', 'escalation_email')
ordering = ('course_id',)


Expand Down
Loading
Loading