diff --git a/cms/djangoapps/contentstore/rest_api/v1/mixins.py b/cms/djangoapps/contentstore/rest_api/v1/mixins.py new file mode 100644 index 000000000000..849a4834905e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/mixins.py @@ -0,0 +1,42 @@ +""" +Common mixins for module. +""" +import json +from unittest.mock import patch + +from rest_framework import status + + +class PermissionAccessMixin: + """ + Mixin for testing permission access for views. + """ + + 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) + self.assertEqual(error, "Authentication credentials were not provided.") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @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. + """ + client, _ = self.create_non_staff_authed_user_client() + response = client.get(self.url) + error = self.get_and_check_developer_response(response) + self.assertEqual(error, "You do not have permission to perform this action.") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py new file mode 100644 index 000000000000..39d9d9127a85 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -0,0 +1,10 @@ +""" +Serializers for v1 contentstore API. +""" +from .settings import CourseSettingsSerializer +from .course_details import CourseDetailsSerializer +from .proctoring import ( + LimitedProctoredExamSettingsSerializer, + ProctoredExamConfigurationSerializer, + ProctoredExamSettingsSerializer, +) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py new file mode 100644 index 000000000000..b799e6d2038f --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py @@ -0,0 +1,59 @@ +""" +API Serializers for course details +""" + +from rest_framework import serializers + +from openedx.core.lib.api.serializers import CourseKeyField + + +class InstructorInfoSerializer(serializers.Serializer): + """ Serializer for instructor info """ + name = serializers.CharField(allow_blank=True) + title = serializers.CharField(allow_blank=True) + organization = serializers.CharField(allow_blank=True) + image = serializers.CharField(allow_blank=True) + bio = serializers.CharField(allow_blank=True) + + +class InstructorsSerializer(serializers.Serializer): + """ Serializer for instructors """ + instructors = InstructorInfoSerializer(many=True, allow_empty=True) + + +class CourseDetailsSerializer(serializers.Serializer): + """ Serializer for course details """ + about_sidebar_html = serializers.CharField(allow_null=True, allow_blank=True) + banner_image_name = serializers.CharField(allow_blank=True) + banner_image_asset_path = serializers.CharField() + certificate_available_date = serializers.DateTimeField() + certificates_display_behavior = serializers.CharField(allow_null=True) + course_id = serializers.CharField() + course_image_asset_path = serializers.CharField(allow_blank=True) + course_image_name = serializers.CharField(allow_blank=True) + description = serializers.CharField(allow_blank=True) + duration = serializers.CharField(allow_blank=True) + effort = serializers.CharField(allow_null=True, allow_blank=True) + end_date = serializers.DateTimeField(allow_null=True) + enrollment_end = serializers.DateTimeField(allow_null=True) + enrollment_start = serializers.DateTimeField(allow_null=True) + entrance_exam_enabled = serializers.CharField(allow_blank=True) + entrance_exam_id = serializers.CharField(allow_blank=True) + entrance_exam_minimum_score_pct = serializers.CharField(allow_blank=True) + instructor_info = InstructorsSerializer() + intro_video = serializers.CharField(allow_null=True) + language = serializers.CharField(allow_null=True) + learning_info = serializers.ListField(child=serializers.CharField(allow_blank=True)) + license = serializers.CharField(allow_null=True) + org = serializers.CharField() + overview = serializers.CharField(allow_blank=True) + pre_requisite_courses = serializers.ListField(child=CourseKeyField()) + run = serializers.CharField() + self_paced = serializers.BooleanField() + short_description = serializers.CharField(allow_blank=True) + start_date = serializers.DateTimeField() + subtitle = serializers.CharField(allow_blank=True) + syllabus = serializers.CharField(allow_null=True) + title = serializers.CharField(allow_blank=True) + video_thumbnail_image_asset_path = serializers.CharField() + video_thumbnail_image_name = serializers.CharField(allow_blank=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/proctoring.py similarity index 97% rename from cms/djangoapps/contentstore/rest_api/v1/serializers.py rename to cms/djangoapps/contentstore/rest_api/v1/serializers/proctoring.py index 2a5f92325f31..a0415f53d873 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/proctoring.py @@ -1,5 +1,5 @@ """ -API Serializers for Contentstore +API Serializers for proctoring """ from rest_framework import serializers diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py new file mode 100644 index 000000000000..0b65389596ae --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py @@ -0,0 +1,44 @@ +""" +API Serializers for course settings +""" + +from rest_framework import serializers + +from openedx.core.lib.api.serializers import CourseKeyField + + +class PossiblePreRequisiteCourseSerializer(serializers.Serializer): + """ Serializer for possible pre requisite course """ + course_key = CourseKeyField() + display_name = serializers.CharField() + lms_link = serializers.CharField() + number = serializers.CharField() + org = serializers.CharField() + rerun_link = serializers.CharField() + run = serializers.CharField() + url = serializers.CharField() + + +class CourseSettingsSerializer(serializers.Serializer): + """ Serializer for course settings """ + about_page_editable = serializers.BooleanField() + can_show_certificate_available_date_field = serializers.BooleanField() + course_display_name = serializers.CharField() + course_display_name_with_default = serializers.CharField() + credit_eligibility_enabled = serializers.BooleanField() + credit_requirements = serializers.DictField(required=False) + enable_extended_course_details = serializers.BooleanField() + enrollment_end_editable = serializers.BooleanField() + is_credit_course = serializers.BooleanField() + is_entrance_exams_enabled = serializers.BooleanField() + is_prerequisite_courses_enabled = serializers.BooleanField() + language_options = serializers.ListField(child=serializers.ListField(child=serializers.CharField())) + lms_link_for_about_page = serializers.URLField() + marketing_enabled = serializers.BooleanField() + mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True) + possible_pre_requisite_courses = PossiblePreRequisiteCourseSerializer(required=False, many=True) + short_description_editable = serializers.BooleanField() + show_min_grade_warning = serializers.BooleanField() + sidebar_html_enabled = serializers.BooleanField() + upgrade_deadline = serializers.DateTimeField(allow_null=True) + use_v2_cert_display_settings = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_course_details.py b/cms/djangoapps/contentstore/rest_api/v1/tests/test_course_details.py new file mode 100644 index 000000000000..8cc62ce28c13 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/tests/test_course_details.py @@ -0,0 +1,108 @@ +""" +Unit tests for course details views. +""" +import json +from unittest.mock import patch + +import ddt +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase + +from ..mixins import PermissionAccessMixin + + +@ddt.ddt +class CourseDetailsViewTest(CourseTestCase, PermissionAccessMixin): + """ + Tests for CourseDetailsView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v1:course_details', + kwargs={"course_id": self.course.id}, + ) + + def test_put_permissions_unauthenticated(self): + """ + Test that an error is returned in the absence of auth credentials. + """ + self.client.logout() + response = self.client.put(self.url) + error = self.get_and_check_developer_response(response) + self.assertEqual(error, "Authentication credentials were not provided.") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_put_permissions_unauthorized(self): + """ + Test that an error is returned if the user is unauthorised. + """ + client, _ = self.create_non_staff_authed_user_client() + response = client.put(self.url) + error = self.get_and_check_developer_response(response) + self.assertEqual(error, "You do not have permission to perform this action.") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) + def test_put_invalid_pre_requisite_course(self): + pre_requisite_course_keys = [str(self.course.id), 'invalid_key'] + request_data = {"pre_requisite_courses": pre_requisite_course_keys} + response = self.client.put(path=self.url, data=json.dumps(request_data), content_type="application/json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json()['error'], 'Invalid prerequisite course key') + + def test_put_course_details(self): + request_data = { + "about_sidebar_html": "", + "banner_image_name": "images_course_image.jpg", + "banner_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg", + "certificate_available_date": "2029-01-02T00:00:00Z", + "certificates_display_behavior": "end", + "course_id": "E2E-101", + "course_image_asset_path": "/static/studio/images/pencils.jpg", + "course_image_name": "bar_course_image_name", + "description": "foo_description", + "duration": "", + "effort": None, + "end_date": "2023-08-01T01:30:00Z", + "enrollment_end": "2023-05-30T01:00:00Z", + "enrollment_start": "2023-05-29T01:00:00Z", + "entrance_exam_enabled": "", + "entrance_exam_id": "", + "entrance_exam_minimum_score_pct": "50", + "intro_video": None, + "language": "creative-commons: ver=4.0 BY NC ND", + "learning_info": [ + "foo", + "bar" + ], + "license": "creative-commons: ver=4.0 BY NC ND", + "org": "edX", + "overview": "
", + "pre_requisite_courses": [], + "run": "course", + "self_paced": None, + "short_description": "", + "start_date": "2023-06-01T01:30:00Z", + "subtitle": "", + "syllabus": None, + "title": "", + "video_thumbnail_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg", + "video_thumbnail_image_name": "images_course_image.jpg", + "instructor_info": { + "instructors": [ + { + "name": "foo bar", + "title": "title", + "organization": "org", + "image": "image", + "bio": "" + } + ] + }, + } + response = self.client.put(path=self.url, data=json.dumps(request_data), content_type="application/json") + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py b/cms/djangoapps/contentstore/rest_api/v1/tests/test_proctoring.py similarity index 100% rename from cms/djangoapps/contentstore/rest_api/v1/tests/test_views.py rename to cms/djangoapps/contentstore/rest_api/v1/tests/test_proctoring.py diff --git a/cms/djangoapps/contentstore/rest_api/v1/tests/test_settings.py b/cms/djangoapps/contentstore/rest_api/v1/tests/test_settings.py new file mode 100644 index 000000000000..8a4267de5721 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/tests/test_settings.py @@ -0,0 +1,80 @@ +""" +Unit tests for course settings views. +""" +import ddt +from django.conf import settings +from django.urls import reverse +from mock import patch +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url +from common.djangoapps.util.course import get_link_for_about_page +from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory + +from ..mixins import PermissionAccessMixin + + +@ddt.ddt +class CourseSettingsViewTest(CourseTestCase, PermissionAccessMixin): + """ + Tests for CourseSettingsView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + 'cms.djangoapps.contentstore:v1:course_settings', + kwargs={"course_id": self.course.id}, + ) + + def test_course_settings_response(self): + """ Check successful response content """ + response = self.client.get(self.url) + expected_response = { + 'about_page_editable': True, + 'can_show_certificate_available_date_field': False, + 'course_display_name': self.course.display_name, + 'course_display_name_with_default': self.course.display_name_with_default, + 'credit_eligibility_enabled': True, + 'enrollment_end_editable': True, + 'enable_extended_course_details': False, + 'is_credit_course': False, + 'is_entrance_exams_enabled': True, + 'is_prerequisite_courses_enabled': False, + 'language_options': settings.ALL_LANGUAGES, + 'lms_link_for_about_page': get_link_for_about_page(self.course), + 'marketing_enabled': False, + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(self.course.id), + 'short_description_editable': True, + 'sidebar_html_enabled': False, + 'show_min_grade_warning': False, + 'upgrade_deadline': None, + 'use_v2_cert_display_settings': False, + } + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_response, response.data) + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREDIT_ELIGIBILITY': True}) + def test_credit_eligibility_setting(self): + """ + Make sure if the feature flag is enabled we have updated the dict keys in response. + """ + _ = CreditCourseFactory(course_key=self.course.id, enabled=True) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('credit_requirements', response.data) + self.assertTrue(response.data['is_credit_course']) + + @patch.dict('django.conf.settings.FEATURES', { + 'ENABLE_PREREQUISITE_COURSES': True, + 'MILESTONES_APP': True, + }) + def test_prerequisite_courses_enabled_setting(self): + """ + Make sure if the feature flags are enabled we have updated the dict keys in response. + """ + response = self.client.get(self.url) + self.assertIn('possible_pre_requisite_courses', response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 83e03bcb73a3..63b2b2d731e5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -4,14 +4,28 @@ from openedx.core.constants import COURSE_ID_PATTERN -from . import views +from .views import ( + CourseDetailsView, + CourseSettingsView, + ProctoredExamSettingsView, +) app_name = 'v1' urlpatterns = [ re_path( fr'^proctored_exam_settings/{COURSE_ID_PATTERN}$', - views.ProctoredExamSettingsView.as_view(), + ProctoredExamSettingsView.as_view(), name="proctored_exam_settings" ), + re_path( + fr'^course_settings/{COURSE_ID_PATTERN}$', + CourseSettingsView.as_view(), + name="course_settings" + ), + re_path( + fr'^course_details/{COURSE_ID_PATTERN}$', + CourseDetailsView.as_view(), + name="course_details" + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py new file mode 100644 index 000000000000..fd653e2038df --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -0,0 +1,6 @@ +""" +Views for v1 contentstore API. +""" +from .course_details import CourseDetailsView +from .settings import CourseSettingsView +from .proctoring import ProctoredExamSettingsView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py new file mode 100644 index 000000000000..d5ccf3c6165e --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py @@ -0,0 +1,155 @@ +""" API Views for course details """ + +import edx_api_doc_tools as apidocs +from django.core.exceptions import ValidationError +from common.djangoapps.util.json_request import JsonResponseBadRequest +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from common.djangoapps.student.auth import has_studio_read_access +from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes +from xmodule.modulestore.django import modulestore + +from ..serializers import CourseDetailsSerializer +from ....utils import update_course_details + + +@view_auth_classes(is_authenticated=True) +class CourseDetailsView(DeveloperErrorViewMixin, APIView): + """ + View for getting and setting the course details. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: CourseDetailsSerializer, + 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): + """ + Get an object containing all the course details. + + **Example Request** + + GET /api/contentstore/v1/course_details/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's details. + + **Example Response** + + ```json + { + "about_sidebar_html": "", + "banner_image_name": "images_course_image.jpg", + "banner_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg", + "certificate_available_date": "2029-01-02T00:00:00Z", + "certificates_display_behavior": "end", + "course_id": "E2E-101", + "course_image_asset_path": "/static/studio/images/pencils.jpg", + "course_image_name": "", + "description": "", + "duration": "", + "effort": null, + "end_date": "2023-08-01T01:30:00Z", + "enrollment_end": "2023-05-30T01:00:00Z", + "enrollment_start": "2023-05-29T01:00:00Z", + "entrance_exam_enabled": "", + "entrance_exam_id": "", + "entrance_exam_minimum_score_pct": "50", + "intro_video": null, + "language": "creative-commons: ver=4.0 BY NC ND", + "learning_info": [], + "license": "creative-commons: ver=4.0 BY NC ND", + "org": "edX", + "overview": "
", + "pre_requisite_courses": [], + "run": "course", + "self_paced": false, + "short_description": "", + "start_date": "2023-06-01T01:30:00Z", + "subtitle": "", + "syllabus": null, + "title": "", + "video_thumbnail_image_asset_path": "/asset-v1:edX+E2E-101+course+type@asset+block@images_course_image.jpg", + "video_thumbnail_image_name": "images_course_image.jpg", + "instructor_info": { + "instructors": [{ + "name": "foo bar", + "title": "title", + "organization": "org", + "image": "image", + "bio": "" + }] + } + } + ``` + """ + course_key = CourseKey.from_string(course_id) + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + course_details = CourseDetails.fetch(course_key) + serializer = CourseDetailsSerializer(course_details) + return Response(serializer.data) + + @apidocs.schema( + body=CourseDetailsSerializer, + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: CourseDetailsSerializer, + 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 put(self, request: Request, course_id: str): + """ + Update a course's details. + + **Example Request** + + PUT /api/contentstore/v1/course_details/{course_id} + + **PUT Parameters** + + The data sent for a put request should follow a similar format as + is returned by a ``GET`` request. Multiple details can be updated in + a single request, however only the ``value`` field can be updated + any other fields, if included, will be ignored. + + Example request data that updates the ``course_details`` the same as in GET method + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned, + along with all the course's details similar to a ``GET`` request. + """ + course_key = CourseKey.from_string(course_id) + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + course_block = modulestore().get_course(course_key) + + try: + updated_data = update_course_details(request, course_key, request.data, course_block) + except ValidationError as err: + return JsonResponseBadRequest({"error": err.message}) + + serializer = CourseDetailsSerializer(updated_data) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views.py b/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py similarity index 98% rename from cms/djangoapps/contentstore/rest_api/v1/views.py rename to cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py index 9675665d2ec7..0f31f5a9434f 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py @@ -1,4 +1,4 @@ -"Contentstore Views" +""" API Views for proctored exam settings and proctoring error """ import copy from opaque_keys.edx.keys import CourseKey @@ -14,7 +14,7 @@ from openedx.core.lib.api.view_utils import view_auth_classes from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from .serializers import ( +from ..serializers import ( LimitedProctoredExamSettingsSerializer, ProctoredExamConfigurationSerializer, ProctoredExamSettingsSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py new file mode 100644 index 000000000000..e42b7d85daf7 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py @@ -0,0 +1,115 @@ +""" API Views for course settings """ + +import edx_api_doc_tools as apidocs +from django.conf import settings +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.djangoapps.student.auth import has_studio_read_access +from lms.djangoapps.certificates.api import can_show_certificate_available_date_field +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes +from xmodule.modulestore.django import modulestore + +from ..serializers import CourseSettingsSerializer +from ....utils import get_course_settings + + +@view_auth_classes(is_authenticated=True) +class CourseSettingsView(DeveloperErrorViewMixin, APIView): + """ + View for getting the settings for a course. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: CourseSettingsSerializer, + 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): + """ + Get an object containing all the course settings. + + **Example Request** + + GET /api/contentstore/v1/course_settings/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's settings. + + **Example Response** + + ```json + { + "about_page_editable": false, + "can_show_certificate_available_date_field": false, + "course_display_name": "E2E Test Course", + "course_display_name_with_default": "E2E Test Course", + "credit_eligibility_enabled": true, + "enable_extended_course_details": true, + "enrollment_end_editable": true, + "is_credit_course": false, + "is_entrance_exams_enabled": true, + "is_prerequisite_courses_enabled": true, + "language_options": [ + [ + "aa", + "Afar" + ], + [ + "uk", + "Ukrainian" + ], + ... + ], + "lms_link_for_about_page": "http://localhost:18000/courses/course-v1:edX+E2E-101+course/about", + "marketing_enabled": true, + "mfe_proctored_exam_settings_url": "", + "possible_pre_requisite_courses": [ + { + "course_key": "course-v1:edX+M12+2T2023", + "display_name": "Differential Equations", + "lms_link": "//localhost:18000/courses/course-v1:edX+M1...", + "number": "M12", + "org": "edX", + "rerun_link": "/course_rerun/course-v1:edX+M12+2T2023", + "run": "2T2023", + "url": "/course/course-v1:edX+M12+2T2023" + }, + ], + "short_description_editable": true, + "show_min_grade_warning": false, + "sidebar_html_enabled": true, + "upgrade_deadline": null, + "use_v2_cert_display_settings": false + } + ``` + """ + course_key = CourseKey.from_string(course_id) + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + with modulestore().bulk_operations(course_key): + course_block = modulestore().get_course(course_key) + settings_context = get_course_settings(request, course_key, course_block) + settings_context.update({ + 'can_show_certificate_available_date_field': can_show_certificate_available_date_field(course_block), + 'course_display_name': course_block.display_name, + 'course_display_name_with_default': course_block.display_name_with_default, + 'use_v2_cert_display_settings': settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False), + }) + + serializer = CourseSettingsSerializer(settings_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 3a8ade2fa964..f111e7ec6dbf 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -7,25 +7,46 @@ from datetime import datetime from django.conf import settings +from django.core.exceptions import ValidationError from django.urls import reverse from django.utils import translation from django.utils.translation import gettext as _ from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryLocator +from milestones import api as milestones_api from pytz import UTC from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled +from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student import auth from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + GlobalStaff, +) +from common.djangoapps.util.course import get_link_for_about_page +from common.djangoapps.util.milestones_helpers import ( + is_prerequisite_courses_enabled, + is_valid_course_key, + remove_prerequisite_course, + set_prerequisite_courses, + get_namespace_choices, + generate_milestone_namespace +) +from openedx.core import toggles as core_toggles from openedx.core.djangoapps.course_apps.toggles import proctoring_settings_modal_view_enabled +from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND from openedx.core.djangoapps.django_comment_common.models import assign_default_role from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.models import SiteConfiguration +from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.lib.courses import course_image_url from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME +from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from cms.djangoapps.contentstore.toggles import ( use_new_text_editor, use_new_video_editor, @@ -46,6 +67,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order + log = logging.getLogger(__name__) @@ -878,3 +900,165 @@ def translation_language(language): translation.activate(previous) else: yield + + +def update_course_details(request, course_key, payload, course_block): + """ + Utils is used to update course details. + It is used for both DRF and django views. + """ + + from .views.entrance_exam import create_entrance_exam, delete_entrance_exam, update_entrance_exam + + # if pre-requisite course feature is enabled set pre-requisite course + if is_prerequisite_courses_enabled(): + prerequisite_course_keys = payload.get('pre_requisite_courses', []) + if prerequisite_course_keys: + if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys): + raise ValidationError(_("Invalid prerequisite course key")) + set_prerequisite_courses(course_key, prerequisite_course_keys) + else: + # None is chosen, so remove the course prerequisites + course_milestones = milestones_api.get_course_milestones( + course_key=course_key, + relationship="requires", + ) + for milestone in course_milestones: + entrance_exam_namespace = generate_milestone_namespace( + get_namespace_choices().get('ENTRANCE_EXAM'), + course_key + ) + if milestone["namespace"] != entrance_exam_namespace: + remove_prerequisite_course(course_key, milestone) + + # If the entrance exams feature has been enabled, we'll need to check for some + # feature-specific settings and handle them accordingly + # We have to be careful that we're only executing the following logic if we actually + # need to create or delete an entrance exam from the specified course + if core_toggles.ENTRANCE_EXAMS.is_enabled(): + course_entrance_exam_present = course_block.entrance_exam_enabled + entrance_exam_enabled = payload.get('entrance_exam_enabled', '') == 'true' + ee_min_score_pct = payload.get('entrance_exam_minimum_score_pct', None) + # If the entrance exam box on the settings screen has been checked... + if entrance_exam_enabled: + # Load the default minimum score threshold from settings, then try to override it + entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + if ee_min_score_pct: + entrance_exam_minimum_score_pct = float(ee_min_score_pct) + if entrance_exam_minimum_score_pct.is_integer(): + entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 + # If there's already an entrance exam defined, we'll update the existing one + if course_entrance_exam_present: + exam_data = { + 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct + } + update_entrance_exam(request, course_key, exam_data) + # If there's no entrance exam defined, we'll create a new one + else: + create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) + + # If the entrance exam box on the settings screen has been unchecked, + # and the course has an entrance exam attached... + elif not entrance_exam_enabled and course_entrance_exam_present: + delete_entrance_exam(request, course_key) + + # Perform the normal update workflow for the CourseDetails model + return CourseDetails.update_from_json(course_key, payload, request.user) + + +def get_course_settings(request, course_key, course_block): + """ + Utils is used to get context of course settings. + It is used for both DRF and django views. + """ + + from .views.course import get_courses_accessible_to_user, _process_courses_list + + credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False) + upload_asset_url = reverse_course_url('assets_handler', course_key) + + # see if the ORG of this course can be attributed to a defined configuration . In that case, the + # course about page should be editable in Studio + publisher_enabled = configuration_helpers.get_value_for_org( + course_block.location.org, + 'ENABLE_PUBLISHER', + settings.FEATURES.get('ENABLE_PUBLISHER', False) + ) + marketing_enabled = configuration_helpers.get_value_for_org( + course_block.location.org, + 'ENABLE_MKTG_SITE', + settings.FEATURES.get('ENABLE_MKTG_SITE', False) + ) + enable_extended_course_details = configuration_helpers.get_value_for_org( + course_block.location.org, + 'ENABLE_EXTENDED_COURSE_DETAILS', + settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False) + ) + + about_page_editable = not publisher_enabled + enrollment_end_editable = GlobalStaff().has_user(request.user) or not publisher_enabled + short_description_editable = configuration_helpers.get_value_for_org( + course_block.location.org, + 'EDITABLE_SHORT_DESCRIPTION', + settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) + ) + sidebar_html_enabled = ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled() + + verified_mode = CourseMode.verified_mode_for_course(course_key, include_expired=True) + upgrade_deadline = (verified_mode and verified_mode.expiration_datetime and + verified_mode.expiration_datetime.isoformat()) + settings_context = { + 'context_course': course_block, + 'course_locator': course_key, + 'lms_link_for_about_page': get_link_for_about_page(course_block), + 'course_image_url': course_image_url(course_block, 'course_image'), + 'banner_image_url': course_image_url(course_block, 'banner_image'), + 'video_thumbnail_image_url': course_image_url(course_block, 'video_thumbnail_image'), + 'details_url': reverse_course_url('settings_handler', course_key), + 'about_page_editable': about_page_editable, + 'marketing_enabled': marketing_enabled, + 'short_description_editable': short_description_editable, + 'sidebar_html_enabled': sidebar_html_enabled, + 'upload_asset_url': upload_asset_url, + 'course_handler_url': reverse_course_url('course_handler', course_key), + 'language_options': settings.ALL_LANGUAGES, + 'credit_eligibility_enabled': credit_eligibility_enabled, + 'is_credit_course': False, + 'show_min_grade_warning': False, + 'enrollment_end_editable': enrollment_end_editable, + 'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(), + 'is_entrance_exams_enabled': core_toggles.ENTRANCE_EXAMS.is_enabled(), + 'enable_extended_course_details': enable_extended_course_details, + 'upgrade_deadline': upgrade_deadline, + 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id), + } + if is_prerequisite_courses_enabled(): + courses, in_process_course_actions = get_courses_accessible_to_user(request) + # exclude current course from the list of available courses + courses = [course for course in courses if course.id != course_key] + if courses: + courses, __ = _process_courses_list(courses, in_process_course_actions) + settings_context.update({'possible_pre_requisite_courses': courses}) + + if credit_eligibility_enabled: + if is_credit_course(course_key): + # get and all credit eligibility requirements + credit_requirements = get_credit_requirements(course_key) + # pair together requirements with same 'namespace' values + paired_requirements = {} + for requirement in credit_requirements: + namespace = requirement.pop("namespace") + paired_requirements.setdefault(namespace, []).append(requirement) + + # if 'minimum_grade_credit' of a course is not set or 0 then + # show warning message to course author. + show_min_grade_warning = False if course_block.minimum_grade_credit > 0 else True # lint-amnesty, pylint: disable=simplifiable-if-expression + settings_context.update( + { + 'is_credit_course': True, + 'credit_requirements': paired_requirements, + 'show_min_grade_warning': show_min_grade_warning, + } + ) + + return settings_context diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 664d1d34da4f..c289242aab3c 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -17,7 +17,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError as DjangoValidationError from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import redirect from django.urls import reverse @@ -26,7 +26,6 @@ from django.views.decorators.http import require_GET, require_http_methods from edx_django_utils.monitoring import function_trace from edx_toggles.toggles import WaffleSwitch -from milestones import api as milestones_api from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator @@ -41,7 +40,6 @@ from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager -from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import ( has_course_author_access, @@ -56,31 +54,19 @@ UserBasedRole, OrgStaffRole ) -from common.djangoapps.util.course import get_link_for_about_page from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json -from common.djangoapps.util.milestones_helpers import ( - is_prerequisite_courses_enabled, - is_valid_course_key, - remove_prerequisite_course, - set_prerequisite_courses, - get_namespace_choices, - generate_milestone_namespace -) from common.djangoapps.util.string_utils import _has_non_ascii_characters from common.djangoapps.xblock_django.api import deprecated_xblocks -from openedx.core import toggles as core_toggles from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course +from openedx.core.djangoapps.credit.api import is_credit_course from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json from openedx.core.lib.course_tabs import CourseTabPluginManager -from openedx.core.lib.courses import course_image_url from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME -from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from organizations.models import Organization from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from xmodule.course_block import CourseBlock, DEFAULT_START_DATE, CourseFields # lint-amnesty, pylint: disable=wrong-import-order @@ -104,6 +90,7 @@ from ..toggles import split_library_view_on_dashboard from ..utils import ( add_instructor, + get_course_settings, get_lms_link_for_item, get_proctored_exam_settings_url, initialize_permissions, @@ -111,11 +98,11 @@ reverse_course_url, reverse_library_url, reverse_url, - reverse_usage_url + reverse_usage_url, + update_course_details, ) from .component import ADVANCED_COMPONENT_TYPES from .helpers import is_content_creator -from .entrance_exam import create_entrance_exam, delete_entrance_exam, update_entrance_exam from .block import create_xblock_info from .library import ( LIBRARIES_ENABLED, @@ -1158,96 +1145,11 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab json: update the Course and About xblocks through the CourseDetails model """ course_key = CourseKey.from_string(course_key_string) - credit_eligibility_enabled = settings.FEATURES.get('ENABLE_CREDIT_ELIGIBILITY', False) + with modulestore().bulk_operations(course_key): course_block = get_course_and_check_access(course_key, request.user) if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': - upload_asset_url = reverse_course_url('assets_handler', course_key) - - # see if the ORG of this course can be attributed to a defined configuration . In that case, the - # course about page should be editable in Studio - publisher_enabled = configuration_helpers.get_value_for_org( - course_block.location.org, - 'ENABLE_PUBLISHER', - settings.FEATURES.get('ENABLE_PUBLISHER', False) - ) - marketing_enabled = configuration_helpers.get_value_for_org( - course_block.location.org, - 'ENABLE_MKTG_SITE', - settings.FEATURES.get('ENABLE_MKTG_SITE', False) - ) - enable_extended_course_details = configuration_helpers.get_value_for_org( - course_block.location.org, - 'ENABLE_EXTENDED_COURSE_DETAILS', - settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False) - ) - - about_page_editable = not publisher_enabled - enrollment_end_editable = GlobalStaff().has_user(request.user) or not publisher_enabled - short_description_editable = configuration_helpers.get_value_for_org( - course_block.location.org, - 'EDITABLE_SHORT_DESCRIPTION', - settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) - ) - sidebar_html_enabled = ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled() - - verified_mode = CourseMode.verified_mode_for_course(course_key, include_expired=True) - upgrade_deadline = (verified_mode and verified_mode.expiration_datetime and - verified_mode.expiration_datetime.isoformat()) - settings_context = { - 'context_course': course_block, - 'course_locator': course_key, - 'lms_link_for_about_page': get_link_for_about_page(course_block), - 'course_image_url': course_image_url(course_block, 'course_image'), - 'banner_image_url': course_image_url(course_block, 'banner_image'), - 'video_thumbnail_image_url': course_image_url(course_block, 'video_thumbnail_image'), - 'details_url': reverse_course_url('settings_handler', course_key), - 'about_page_editable': about_page_editable, - 'marketing_enabled': marketing_enabled, - 'short_description_editable': short_description_editable, - 'sidebar_html_enabled': sidebar_html_enabled, - 'upload_asset_url': upload_asset_url, - 'course_handler_url': reverse_course_url('course_handler', course_key), - 'language_options': settings.ALL_LANGUAGES, - 'credit_eligibility_enabled': credit_eligibility_enabled, - 'is_credit_course': False, - 'show_min_grade_warning': False, - 'enrollment_end_editable': enrollment_end_editable, - 'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(), - 'is_entrance_exams_enabled': core_toggles.ENTRANCE_EXAMS.is_enabled(), - 'enable_extended_course_details': enable_extended_course_details, - 'upgrade_deadline': upgrade_deadline, - 'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id), - } - if is_prerequisite_courses_enabled(): - courses, in_process_course_actions = get_courses_accessible_to_user(request) - # exclude current course from the list of available courses - courses = [course for course in courses if course.id != course_key] - if courses: - courses, __ = _process_courses_list(courses, in_process_course_actions) - settings_context.update({'possible_pre_requisite_courses': courses}) - - if credit_eligibility_enabled: - if is_credit_course(course_key): - # get and all credit eligibility requirements - credit_requirements = get_credit_requirements(course_key) - # pair together requirements with same 'namespace' values - paired_requirements = {} - for requirement in credit_requirements: - namespace = requirement.pop("namespace") - paired_requirements.setdefault(namespace, []).append(requirement) - - # if 'minimum_grade_credit' of a course is not set or 0 then - # show warning message to course author. - show_min_grade_warning = False if course_block.minimum_grade_credit > 0 else True # lint-amnesty, pylint: disable=simplifiable-if-expression - settings_context.update( - { - 'is_credit_course': True, - 'credit_requirements': paired_requirements, - 'show_min_grade_warning': show_min_grade_warning, - } - ) - + settings_context = get_course_settings(request, course_key, course_block) return render_to_response('settings.html', settings_context) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): # pylint: disable=too-many-nested-blocks if request.method == 'GET': @@ -1259,63 +1161,12 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab ) # For every other possible method type submitted by the caller... else: - # if pre-requisite course feature is enabled set pre-requisite course - if is_prerequisite_courses_enabled(): - prerequisite_course_keys = request.json.get('pre_requisite_courses', []) - if prerequisite_course_keys: - if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys): - return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")}) - set_prerequisite_courses(course_key, prerequisite_course_keys) - else: - # None is chosen, so remove the course prerequisites - course_milestones = milestones_api.get_course_milestones( - course_key=course_key, - relationship="requires", - ) - for milestone in course_milestones: - entrance_exam_namespace = generate_milestone_namespace( - get_namespace_choices().get('ENTRANCE_EXAM'), - course_key - ) - if milestone["namespace"] != entrance_exam_namespace: - remove_prerequisite_course(course_key, milestone) - - # If the entrance exams feature has been enabled, we'll need to check for some - # feature-specific settings and handle them accordingly - # We have to be careful that we're only executing the following logic if we actually - # need to create or delete an entrance exam from the specified course - if core_toggles.ENTRANCE_EXAMS.is_enabled(): - course_entrance_exam_present = course_block.entrance_exam_enabled - entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true' - ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None) - # If the entrance exam box on the settings screen has been checked... - if entrance_exam_enabled: - # Load the default minimum score threshold from settings, then try to override it - entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) - if ee_min_score_pct: - entrance_exam_minimum_score_pct = float(ee_min_score_pct) - if entrance_exam_minimum_score_pct.is_integer(): - entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 - # If there's already an entrance exam defined, we'll update the existing one - if course_entrance_exam_present: - exam_data = { - 'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct - } - update_entrance_exam(request, course_key, exam_data) - # If there's no entrance exam defined, we'll create a new one - else: - create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct) - - # If the entrance exam box on the settings screen has been unchecked, - # and the course has an entrance exam attached... - elif not entrance_exam_enabled and course_entrance_exam_present: - delete_entrance_exam(request, course_key) - - # Perform the normal update workflow for the CourseDetails model - return JsonResponse( - CourseDetails.update_from_json(course_key, request.json, request.user), - encoder=CourseSettingsEncoder - ) + try: + update_data = update_course_details(request, course_key, request.json, course_block) + except DjangoValidationError as err: + return JsonResponseBadRequest({"error": err.message}) + + return JsonResponse(update_data, encoder=CourseSettingsEncoder) @login_required diff --git a/setup.cfg b/setup.cfg index 7b1be0be92e8..74a23a55cb4c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -73,3 +73,4 @@ ignore_imports = cms.djangoapps.contentstore.views.preview -> lms.djangoapps.lms_xblock.field_data cms.envs.common -> lms.djangoapps.lms_xblock.mixin cms.envs.test -> lms.envs.test + cms.djangoapps.contentstore.rest_api.v1.views.settings -> lms.djangoapps.certificates.api