diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py index 70bb851927..765246258b 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py @@ -81,3 +81,13 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin): assert field in content.keys() for field in absent_fields: assert field not in content.keys() + + @ddt.data( + ("ENABLE_EDXNOTES", "edxnotes"), + ("ENABLE_OTHER_COURSE_SETTINGS", "other_course_settings"), + ) + @ddt.unpack + def test_disabled_fetch_all_query_param(self, setting, excluded_field): + with override_settings(FEATURES={setting: False}): + resp = self.client.get(self.url, {"fetch_all": 0}) + assert excluded_field not in resp.data diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py index 9991f13889..7516bba372 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py @@ -10,6 +10,7 @@ from rest_framework.views import APIView from xmodule.modulestore.django import modulestore from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from cms.djangoapps.contentstore.api.views.utils import get_bool_param from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from ..serializers import CourseAdvancedSettingsSerializer @@ -39,9 +40,14 @@ class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView): apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), apidocs.string_parameter( "filter_fields", - apidocs.ParameterLocation.PATH, + apidocs.ParameterLocation.QUERY, description="Comma separated list of fields to filter", ), + apidocs.string_parameter( + "fetch_all", + apidocs.ParameterLocation.QUERY, + description="Specifies whether to fetch all settings or only enabled ones", + ), ], responses={ 200: CourseAdvancedSettingsSerializer, @@ -112,7 +118,13 @@ class AdvancedCourseSettingsView(DeveloperErrorViewMixin, APIView): if not has_studio_read_access(request.user, course_key): self.permission_denied(request) course_block = modulestore().get_course(course_key) - return Response(CourseMetadata.fetch_all( + fetch_all = get_bool_param(request, 'fetch_all', True) + if fetch_all: + return Response(CourseMetadata.fetch_all( + course_block, + filter_fields=filter_query_data.cleaned_data['filter_fields'], + )) + return Response(CourseMetadata.fetch( course_block, filter_fields=filter_query_data.cleaned_data['filter_fields'], )) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers.py b/cms/djangoapps/contentstore/rest_api/v1/serializers.py index 2a5f92325f..627c2e6619 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers.py @@ -29,3 +29,31 @@ class ProctoredExamConfigurationSerializer(serializers.Serializer): proctored_exam_settings = ProctoredExamSettingsSerializer() available_proctoring_providers = serializers.ChoiceField(get_available_providers()) course_start_date = serializers.DateTimeField() + + +class ProctoringErrorModelSerializer(serializers.Serializer): + """ + Serializer for proctoring error model item. + """ + deprecated = serializers.BooleanField() + display_name = serializers.CharField() + help = serializers.CharField() + hide_on_enabled_publisher = serializers.BooleanField() + value = serializers.CharField() + + +class ProctoringErrorListSerializer(serializers.Serializer): + """ + Serializer for proctoring error list. + """ + key = serializers.CharField() + message = serializers.CharField() + model = ProctoringErrorModelSerializer() + + +class ProctoringErrorsSerializer(serializers.Serializer): + """ + Serializer for proctoring errors with url to proctored exam settings. + """ + mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True) + proctoring_errors = ProctoringErrorListSerializer(many=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py b/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py index 2a9e2d881a..291e8637e9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py +++ b/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py @@ -1,6 +1,7 @@ """ Unit tests for Contentstore views. """ +import json import ddt from mock import patch @@ -12,6 +13,7 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.test import APITestCase +from cms.djangoapps.contentstore.tests.utils import CourseTestCase from common.djangoapps.student.tests.factories import GlobalStaffFactory from common.djangoapps.student.tests.factories import InstructorFactory from common.djangoapps.student.tests.factories import UserFactory @@ -437,3 +439,54 @@ class ProctoringExamSettingsPostTests(ProctoringExamSettingsTestMixin, ModuleSto updated = modulestore().get_item(self.course.location) assert updated.enable_proctored_exams is False assert updated.proctoring_provider == 'null' + + +@ddt.ddt +class CourseProctoringErrorsViewTest(CourseTestCase): + """ + Tests for ProctoringErrorsView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v1:proctoring_errors', + kwargs={"course_id": self.course.id}, + ) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() + + def get_and_check_developer_response(self, response): + """ + Make basic asserting about the presence of an error response, and return the developer response. + """ + content = json.loads(response.content.decode("utf-8")) + assert "developer_message" in content + return content["developer_message"] + + def test_permissions_unauthenticated(self): + """ + Test that an error is returned in the absence of auth credentials. + """ + self.client.logout() + response = self.client.get(self.url) + error = self.get_and_check_developer_response(response) + assert error == "Authentication credentials were not provided." + + @patch.dict('django.conf.settings.FEATURES', {'DISABLE_ADVANCED_SETTINGS': True}) + def test_permissions_unauthorized(self): + """ + Test that an error is returned if the user is unauthorised. + """ + response = self.non_staff_client.get(self.url) + error = self.get_and_check_developer_response(response) + assert error == "You do not have permission to perform this action." + + @ddt.data(False, True) + def test_disable_advanced_settings_feature(self, disable_advanced_settings): + """ + If this feature is enabled, only Django Staff/Superuser should be able to see the proctoring errors. + For non-staff users the proctoring errors should be unavailable. + """ + with override_settings(FEATURES={'DISABLE_ADVANCED_SETTINGS': disable_advanced_settings}): + response = self.non_staff_client.get(self.url) + self.assertEqual(response.status_code, 403 if disable_advanced_settings else 200) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 83e03bcb73..57106e24f3 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -14,4 +14,9 @@ urlpatterns = [ views.ProctoredExamSettingsView.as_view(), name="proctored_exam_settings" ), + re_path( + fr'^proctoring_errors/{COURSE_ID_PATTERN}$', + views.ProctoringErrorsView.as_view(), + name="proctoring_errors" + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views.py b/cms/djangoapps/contentstore/rest_api/v1/views.py index 9675665d2e..1a42d50007 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views.py @@ -1,23 +1,29 @@ "Contentstore Views" import copy +from django.conf import settings +import edx_api_doc_tools as apidocs from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.exceptions import NotFound +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from cms.djangoapps.contentstore.views.course import get_course_and_check_access +from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url from cms.djangoapps.models.settings.course_metadata import CourseMetadata +from common.djangoapps.student.auth import has_studio_advanced_settings_access from xmodule.course_block import get_available_providers # lint-amnesty, pylint: disable=wrong-import-order from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled -from openedx.core.lib.api.view_utils import view_auth_classes +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from .serializers import ( LimitedProctoredExamSettingsSerializer, ProctoredExamConfigurationSerializer, - ProctoredExamSettingsSerializer + ProctoredExamSettingsSerializer, + ProctoringErrorsSerializer, ) @@ -182,3 +188,80 @@ class ProctoredExamSettingsView(APIView): ) return course_block + + +@view_auth_classes(is_authenticated=True) +class ProctoringErrorsView(DeveloperErrorViewMixin, APIView): + """ + View for getting the proctoring errors for a course with url to proctored exam settings. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: ProctoringErrorsSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str) -> Response: + """ + Get an object containing proctoring errors in a course. + + **Example Request** + + GET /api/contentstore/v1/proctoring_errors/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a list of object proctoring errors. + Also response contains mfe proctored exam settings url. + For each item returned an object that contains the following fields: + + * **key**: This is proctoring settings key. + * **message**: This is a description for proctoring error. + * **model**: This is proctoring provider model object. + + **Example Response** + + ```json + { + "mfe_proctored_exam_settings_url": "http://course-authoring-mfe/course/course_key/proctored-exam-settings", + "proctoring_errors": [ + { + "key": "proctoring_provider", + "message": "The proctoring provider cannot be modified after a course has started.", + "model": { + "value": "null", + "display_name": "Proctoring Provider", + "help": "Enter the proctoring provider you want to use for this course run.", + "deprecated": false, + "hide_on_enabled_publisher": false + }} + ], + } + ``` + """ + course_key = CourseKey.from_string(course_id) + if not has_studio_advanced_settings_access(request.user): + self.permission_denied(request) + + course_block = modulestore().get_course(course_key) + advanced_dict = CourseMetadata.fetch(course_block) + if settings.FEATURES.get('DISABLE_MOBILE_COURSE_AVAILABLE', False): + advanced_dict.get('mobile_available')['deprecated'] = True + + proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user) + proctoring_context = { + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_key), + 'proctoring_errors': proctoring_errors, + } + + serializer = ProctoringErrorsSerializer(data=proctoring_context) + serializer.is_valid(raise_exception=True) + return Response(serializer.data)