feat: Create DRF for course settings and course details views out of current Django views (#32397)
This commit is contained in:
42
cms/djangoapps/contentstore/rest_api/v1/mixins.py
Normal file
42
cms/djangoapps/contentstore/rest_api/v1/mixins.py
Normal file
@@ -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)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Serializers for v1 contentstore API.
|
||||
"""
|
||||
from .settings import CourseSettingsSerializer
|
||||
from .course_details import CourseDetailsSerializer
|
||||
from .proctoring import (
|
||||
LimitedProctoredExamSettingsSerializer,
|
||||
ProctoredExamConfigurationSerializer,
|
||||
ProctoredExamSettingsSerializer,
|
||||
ProctoringErrorsSerializer,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
API Serializers for Contentstore
|
||||
API Serializers for proctoring
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
@@ -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()
|
||||
@@ -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": "<section class=\"about\"></section>",
|
||||
"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)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
Unit tests for Contentstore views.
|
||||
"""
|
||||
import json
|
||||
|
||||
import ddt
|
||||
from mock import patch
|
||||
from django.conf import settings
|
||||
@@ -22,6 +20,8 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from ..mixins import PermissionAccessMixin
|
||||
|
||||
|
||||
class ProctoringExamSettingsTestMixin():
|
||||
""" setup for proctored exam settings tests """
|
||||
@@ -442,7 +442,7 @@ class ProctoringExamSettingsPostTests(ProctoringExamSettingsTestMixin, ModuleSto
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseProctoringErrorsViewTest(CourseTestCase):
|
||||
class CourseProctoringErrorsViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"""
|
||||
Tests for ProctoringErrorsView.
|
||||
"""
|
||||
@@ -455,32 +455,6 @@ class CourseProctoringErrorsViewTest(CourseTestCase):
|
||||
)
|
||||
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
|
||||
|
||||
def get_and_check_developer_response(self, response):
|
||||
"""
|
||||
Make basic asserting about the presence of an error response, and return the developer response.
|
||||
"""
|
||||
content = json.loads(response.content.decode("utf-8"))
|
||||
assert "developer_message" in content
|
||||
return content["developer_message"]
|
||||
|
||||
def test_permissions_unauthenticated(self):
|
||||
"""
|
||||
Test that an error is returned in the absence of auth credentials.
|
||||
"""
|
||||
self.client.logout()
|
||||
response = self.client.get(self.url)
|
||||
error = self.get_and_check_developer_response(response)
|
||||
assert error == "Authentication credentials were not provided."
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_ADVANCED_SETTINGS': True})
|
||||
def test_permissions_unauthorized(self):
|
||||
"""
|
||||
Test that an error is returned if the user is unauthorised.
|
||||
"""
|
||||
response = self.non_staff_client.get(self.url)
|
||||
error = self.get_and_check_developer_response(response)
|
||||
assert error == "You do not have permission to perform this action."
|
||||
|
||||
@ddt.data(False, True)
|
||||
def test_disable_advanced_settings_feature(self, disable_advanced_settings):
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -4,19 +4,34 @@ from django.urls import re_path
|
||||
|
||||
from openedx.core.constants import COURSE_ID_PATTERN
|
||||
|
||||
from . import views
|
||||
from .views import (
|
||||
CourseDetailsView,
|
||||
CourseSettingsView,
|
||||
ProctoredExamSettingsView,
|
||||
ProctoringErrorsView,
|
||||
)
|
||||
|
||||
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'^proctoring_errors/{COURSE_ID_PATTERN}$',
|
||||
views.ProctoringErrorsView.as_view(),
|
||||
ProctoringErrorsView.as_view(),
|
||||
name="proctoring_errors"
|
||||
),
|
||||
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"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Views for v1 contentstore API.
|
||||
"""
|
||||
from .course_details import CourseDetailsView
|
||||
from .settings import CourseSettingsView
|
||||
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView
|
||||
155
cms/djangoapps/contentstore/rest_api/v1/views/course_details.py
Normal file
155
cms/djangoapps/contentstore/rest_api/v1/views/course_details.py
Normal file
@@ -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": "<section class='about'></section>",
|
||||
"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)
|
||||
@@ -1,4 +1,4 @@
|
||||
"Contentstore Views"
|
||||
""" API Views for proctored exam settings and proctoring error """
|
||||
import copy
|
||||
|
||||
from django.conf import settings
|
||||
@@ -19,7 +19,7 @@ from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from .serializers import (
|
||||
from ..serializers import (
|
||||
LimitedProctoredExamSettingsSerializer,
|
||||
ProctoredExamConfigurationSerializer,
|
||||
ProctoredExamSettingsSerializer,
|
||||
115
cms/djangoapps/contentstore/rest_api/v1/views/settings.py
Normal file
115
cms/djangoapps/contentstore/rest_api/v1/views/settings.py
Normal file
@@ -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)
|
||||
@@ -9,6 +9,7 @@ from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
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 _
|
||||
@@ -17,25 +18,45 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import LibraryLocator
|
||||
from openedx_events.content_authoring.data import DuplicatedXBlockData
|
||||
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
|
||||
from milestones import api as milestones_api
|
||||
from pytz import UTC
|
||||
from xblock.fields import Scope
|
||||
|
||||
from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.edxmako.services import MakoService
|
||||
from common.djangoapps.student import auth
|
||||
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
|
||||
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 common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
|
||||
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.discussions.models import DiscussionsConfiguration
|
||||
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_advanced_settings_page,
|
||||
use_new_course_outline_page,
|
||||
@@ -58,6 +79,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, py
|
||||
from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.services import SettingsService, ConfigurationService, TeamsConfigurationService
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1125,6 +1147,168 @@ def load_services_for_studio(runtime, user):
|
||||
runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class StudioPermissionsService:
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
|
||||
@@ -17,7 +17,7 @@ from ccx_keys.locator import CCXLocator
|
||||
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.csrf import ensure_csrf_cookie
|
||||
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.course_metadata import CourseMetadata
|
||||
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,
|
||||
@@ -55,31 +53,19 @@ from common.djangoapps.student.roles import (
|
||||
GlobalStaff,
|
||||
UserBasedRole
|
||||
)
|
||||
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
|
||||
@@ -103,6 +89,7 @@ from ..tasks import rerun_course as rerun_course_task
|
||||
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,
|
||||
get_subsections_by_assignment_type,
|
||||
@@ -113,10 +100,10 @@ from ..utils import (
|
||||
reverse_url,
|
||||
reverse_usage_url,
|
||||
update_course_discussions_settings,
|
||||
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)
|
||||
try:
|
||||
update_data = update_course_details(request, course_key, request.json, course_block)
|
||||
except DjangoValidationError as err:
|
||||
return JsonResponseBadRequest({"error": err.message})
|
||||
|
||||
# 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
|
||||
)
|
||||
return JsonResponse(update_data, encoder=CourseSettingsEncoder)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -153,6 +153,7 @@ ignore_imports =
|
||||
# -> openedx.features.discounts.applicability
|
||||
# -> openedx.features.enterprise_support.utils
|
||||
openedx.features.enterprise_support.utils -> lms.djangoapps.branding.api
|
||||
cms.djangoapps.contentstore.rest_api.v1.views.settings -> lms.djangoapps.certificates.api
|
||||
|
||||
|
||||
[importlinter:contract:2]
|
||||
|
||||
Reference in New Issue
Block a user