From 66011119823a34565279df9a5d1df924ee2d2d8e Mon Sep 17 00:00:00 2001 From: Michael Roytman Date: Wed, 8 Jul 2020 16:59:45 -0400 Subject: [PATCH] add rest_api for contentstore app, including a proctored_exam_settings view --- .../contentstore/rest_api/__init__.py | 0 cms/djangoapps/contentstore/rest_api/urls.py | 12 ++ .../contentstore/rest_api/v1/serializers.py | 24 ++++ .../rest_api/v1/tests/__init__.py | 0 .../rest_api/v1/tests/test_views.py | 107 ++++++++++++++++++ .../contentstore/rest_api/v1/urls.py | 16 +++ .../contentstore/rest_api/v1/views.py | 102 +++++++++++++++++ cms/urls.py | 5 + 8 files changed, 266 insertions(+) create mode 100644 cms/djangoapps/contentstore/rest_api/__init__.py create mode 100644 cms/djangoapps/contentstore/rest_api/urls.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/serializers.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/tests/__init__.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/urls.py create mode 100644 cms/djangoapps/contentstore/rest_api/v1/views.py diff --git a/cms/djangoapps/contentstore/rest_api/__init__.py b/cms/djangoapps/contentstore/rest_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/contentstore/rest_api/urls.py b/cms/djangoapps/contentstore/rest_api/urls.py new file mode 100644 index 0000000000..cbb91a9a80 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/urls.py @@ -0,0 +1,12 @@ +""" +Contentstore API URLs. +""" + +from django.urls import include, re_path +from .v1 import urls as v1_urls + +app_name = 'cms.djangoapps.contentstore' + +urlpatterns = [ + re_path(r'^v1/', include(v1_urls)) +] diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers.py b/cms/djangoapps/contentstore/rest_api/v1/serializers.py new file mode 100644 index 0000000000..0eba034837 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers.py @@ -0,0 +1,24 @@ +""" +API Serializers for Contentstore +""" + +from rest_framework import serializers + +from common.lib.xmodule.xmodule.course_module import get_available_providers + + +class ProctoredExamSettingsSerializer(serializers.Serializer): + """ Serializer for proctored exam settings. """ + enable_proctored_exams = serializers.BooleanField() + allow_proctoring_opt_out = serializers.BooleanField() + proctoring_provider = serializers.CharField() + proctoring_escalation_email = serializers.CharField() + create_zendesk_tickets = serializers.BooleanField() + + +class ProctoredExamConfigurationSerializer(serializers.Serializer): + """ Serializer for various metadata associated with proctored exam settings. """ + proctored_exam_settings = ProctoredExamSettingsSerializer() + available_proctoring_providers = serializers.ChoiceField(get_available_providers()) + course_start_date = serializers.DateTimeField() + is_staff = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py b/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py new file mode 100644 index 0000000000..d3677e3b79 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py @@ -0,0 +1,107 @@ +""" +Unit tests for Contentstore views. +""" + +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APITestCase +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class ProctoringExamSettingsGetTests(SharedModuleStoreTestCase, APITestCase): + """ Tests for proctored exam settings GETs """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course') + cls.other_course_key = CourseKey.from_string('course-v1:edX+ToyX_Other_Course+Toy_Course') + cls.course = cls.create_course_from_course_key(cls.course_key) + cls.other_course = cls.create_course_from_course_key(cls.other_course_key) + cls.password = 'password' + cls.student = UserFactory.create(username='student', password=cls.password) + cls.global_staff = GlobalStaffFactory(username='global-staff', password=cls.password) + cls.course_instructor = InstructorFactory( + username='instructor', + password=cls.password, + course_key=cls.course.id, + ) + cls.other_course_instructor = InstructorFactory( + username='other-course-instructor', + password=cls.password, + course_key=cls.other_course.id, + ) + + def tearDown(self): + super().tearDown() + self.client.logout() + + @classmethod + def create_course_from_course_key(cls, course_key): + return CourseFactory.create( + org=course_key.org, + course=course_key.course, + run=course_key.run + ) + + def get_url(self, course_key): + return reverse( + 'cms.djangoapps.contentstore:v1:proctored_exam_settings', + kwargs={'course_id': course_key} + ) + + def get_expected_response_data(self, course, user): + return { + 'proctored_exam_settings': { + 'enable_proctored_exams': course.enable_proctored_exams, + 'allow_proctoring_opt_out': course.allow_proctoring_opt_out, + 'proctoring_provider': course.proctoring_provider, + 'proctoring_escalation_email': course.proctoring_escalation_email, + 'create_zendesk_tickets': course.create_zendesk_tickets, + }, + 'course_start_date': '2030-01-01T00:00:00Z', + 'available_proctoring_providers': ['null'], + 'is_staff': user.is_staff, + } + + def test_403_if_student(self): + self.client.login(username=self.student.username, password=self.password) + url = self.get_url(self.course.id) + response = self.client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_403_if_instructor_in_another_course(self): + self.client.login( + username=self.other_course_instructor.username, + password=self.password + ) + url = self.get_url(self.course.id) + response = self.client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_200_global_staff(self): + self.client.login(username=self.global_staff.username, password=self.password) + url = self.get_url(self.course.id) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data == self.get_expected_response_data(self.course, self.global_staff) + + def test_200_course_instructor(self): + self.client.login(username=self.course_instructor.username, password=self.password) + url = self.get_url(self.course.id) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data == self.get_expected_response_data(self.course, self.course_instructor) + + def test_404_no_course_module(self): + course_id = 'course-v1:edX+ToyX_Nonexistent_Course+Toy_Course' + self.client.login(username=self.global_staff, password=self.password) + url = self.get_url(course_id) + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.data == 'Course with course_id {} does not exist.'.format(course_id) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py new file mode 100644 index 0000000000..14ffd948bb --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -0,0 +1,16 @@ +""" Contenstore API v1 URLs. """ + +from django.urls import re_path + +from . import views +from openedx.core.constants import COURSE_ID_PATTERN + +app_name = 'v1' + +urlpatterns = [ + re_path( + r'^proctored_exam_settings/{}$'.format(COURSE_ID_PATTERN), + views.proctored_exam_settings, + name="proctored_exam_settings" + ), +] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views.py b/cms/djangoapps/contentstore/rest_api/v1/views.py new file mode 100644 index 0000000000..d9dc1e72b2 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views.py @@ -0,0 +1,102 @@ +"Contentstore Views" + +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from common.lib.xmodule.xmodule.course_module import get_available_providers +from contentstore.views.course import get_course_and_check_access +from models.settings.course_metadata import CourseMetadata +from openedx.core.lib.api.view_utils import view_auth_classes +from xmodule.modulestore.django import modulestore + +from contentstore.rest_api.v1.serializers import ProctoredExamConfigurationSerializer + + +@api_view(['GET']) +@view_auth_classes() +def proctored_exam_settings(request, course_id): + """ + A view for retrieving information about proctored exam settings for a course. + + Path: ``/api/contentstore/v1/proctored_exam_settings/{course_id}`` + + Accepts: [GET] + + ------------------------------------------------------------------------------------ + GET + ------------------------------------------------------------------------------------ + + **Returns** + + * 200: OK - Contains a set of course proctored exam settings. + * 401: The requesting user is not authenticated. + * 403: The requesting user lacks access to the course. + * 404: The requested course does not exist. + + **Response** + + In the case of a 200 response code, the response will proctored exam settings data + as well as other metadata about the course or the requesting user that are necessary + for rendering the settings page. + + **Example** + + { + "proctored_exam_settings": { + "enable_proctored_exams": true, + "allow_proctoring_opt_out": true, + "proctoring_provider": "mockprock", + "proctoring_escalation_email": null, + "create_zendesk_tickets": true + }, + "available_proctoring_providers": [ + "mockprock", + "proctortrack" + ], + "course_start_date": "2013-02-05T05:00:00Z", + "is_staff": true + } + """ + course_key = CourseKey.from_string(course_id) + with modulestore().bulk_operations(course_key): + if request.method == 'GET': + course_module = get_course_and_check_access(course_key, request.user) + + if not course_module: + return Response( + 'Course with course_id {} does not exist.'.format(course_id), + status=status.HTTP_404_NOT_FOUND + ) + + course_metadata = CourseMetadata().fetch_all(course_module) + data = {} + + # specify only the advanced settings we want to return + proctored_exam_settings_advanced_settings_keys = [ + 'enable_proctored_exams', + 'allow_proctoring_opt_out', + 'proctoring_provider', + 'proctoring_escalation_email', + 'create_zendesk_tickets', + 'start' + ] + proctored_exam_settings_data = { + setting_key: setting_value.get('value') + for (setting_key, setting_value) in course_metadata.items() + if setting_key in proctored_exam_settings_advanced_settings_keys + } + + data['proctored_exam_settings'] = proctored_exam_settings_data + data['available_proctoring_providers'] = get_available_providers() + + # move start key:value out of proctored_exam_settings dictionary and change key + data['course_start_date'] = proctored_exam_settings_data['start'] + del data['proctored_exam_settings']['start'] + + data['is_staff'] = request.user.is_staff + + serializer = ProctoredExamConfigurationSerializer(data) + + return Response(serializer.data) diff --git a/cms/urls.py b/cms/urls.py index 3bf3901bc2..a260ea9fa1 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -300,3 +300,8 @@ if 'openedx.testing.coverage_context_listener' in settings.INSTALLED_APPS: from openedx.core.djangoapps.plugins import constants as plugin_constants, plugin_urls urlpatterns.extend(plugin_urls.get_patterns(plugin_constants.ProjectType.CMS)) + +# Contentstore +urlpatterns += [ + url(r'^api/contentstore/', include('cms.djangoapps.contentstore.rest_api.urls')) +]