diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers.py b/cms/djangoapps/contentstore/rest_api/v1/serializers.py
index 38b614ff976b..2bcbecf23180 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers.py
@@ -4,6 +4,7 @@
from rest_framework import serializers
+from openedx.core.lib.api.serializers import CourseKeyField
from xmodule.course_block import get_available_providers
@@ -64,3 +65,91 @@ class CourseGradingSerializer(serializers.Serializer):
default_grade_designations = serializers.ListSerializer(
child=serializers.CharField()
)
+
+
+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)
+
+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_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 23422cfc393e..58aaf2743e67 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/urls.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py
@@ -19,4 +19,14 @@
views.CourseGradingView.as_view(),
name="course_grading"
),
+ re_path(
+ fr'^course_settings/{COURSE_ID_PATTERN}$',
+ views.CourseSettingsView.as_view(),
+ name="course_settings"
+ ),
+ re_path(
+ fr'^course_details/{COURSE_ID_PATTERN}$',
+ views.CourseDetailsView.as_view(),
+ name="course_details"
+ ),
]
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views.py b/cms/djangoapps/contentstore/rest_api/v1/views.py
index 9767237c7993..962939f3cb5f 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views.py
@@ -3,6 +3,8 @@
import edx_api_doc_tools as apidocs
from django.conf import settings
+from django.core.exceptions import ValidationError
+from common.djangoapps.util.json_request import JsonResponseBadRequest
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.exceptions import NotFound
@@ -14,10 +16,12 @@
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from common.djangoapps.student.auth import has_studio_read_access
+from lms.djangoapps.certificates.api import can_show_certificate_available_date_field
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.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.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
@@ -26,10 +30,12 @@
ProctoredExamConfigurationSerializer,
ProctoredExamSettingsSerializer,
CourseGradingModelSerializer,
- CourseGradingSerializer
+ CourseGradingSerializer,
+ CourseDetailsSerializer,
+ CourseSettingsSerializer
)
-from ...utils import get_course_grading
+from ...utils import get_course_grading, get_course_settings, update_course_details
@view_auth_classes()
@@ -349,3 +355,219 @@ def post(self, request: Request, course_id: str):
updated_data = CourseGradingModel.update_from_json(course_key, request.data, request.user)
serializer = CourseGradingModelSerializer(updated_data)
return Response(serializer.data)
+
+
+@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)
+
+@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 140b170e9e2b..1eb9949775b3 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -7,26 +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 is_credit_course
+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
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
@@ -754,3 +774,165 @@ def get_course_grading(course_key):
}
return grading_context
+
+
+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 735adbc0316e..702308807688 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,32 +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.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.djangoapps.user_api.models import UserPreference
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
@@ -105,6 +90,7 @@
from ..toggles import split_library_view_on_dashboard
from ..utils import (
add_instructor,
+ get_course_settings,
get_course_grading,
get_lms_link_for_item,
get_proctored_exam_settings_url,
@@ -113,11 +99,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,
@@ -1160,103 +1146,10 @@ 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())
-
- date_placeholder_format = configuration_helpers.get_value_for_org(
- course_block.location.org,
- 'SCHEDULE_DETAIL_FORMAT',
- settings.SCHEDULE_DETAIL_FORMAT
- ).upper()
-
- 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),
- 'date_placeholder_format': date_placeholder_format,
- }
- 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
@@ -1275,63 +1168,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 fc5a2dee3e83..8fdf9c76631a 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -74,3 +74,4 @@ ignore_imports =
cms.envs.common -> lms.djangoapps.lms_xblock.mixin
cms.envs.test -> lms.envs.test
cms.djangoapps.contentstore.permissions -> lms.djangoapps.courseware.rules
+ cms.djangoapps.contentstore.rest_api.v1.views -> lms.djangoapps.certificates.api