Skip to content

Commit 8940cb3

Browse files
pkulkarkxitij2000
authored andcommitted
feat: Extend settings handler to be accessible via api (#533)
(cherry picked from commit bca8d34)
1 parent 012aa22 commit 8940cb3

File tree

6 files changed

+229
-59
lines changed

6 files changed

+229
-59
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
Tests for the course advanced settings API.
3+
"""
4+
import json
5+
6+
import ddt
7+
from django.urls import reverse
8+
from rest_framework import status
9+
10+
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
11+
12+
13+
@ddt.ddt
14+
class CourseDetailsSettingViewTest(CourseTestCase):
15+
"""
16+
Tests for DetailsSettings API View.
17+
"""
18+
19+
def setUp(self):
20+
super().setUp()
21+
self.url = reverse(
22+
"cms.djangoapps.contentstore:v0:course_details_settings",
23+
kwargs={"course_id": self.course.id},
24+
)
25+
26+
def get_and_check_developer_response(self, response):
27+
"""
28+
Make basic asserting about the presence of an error response, and return the developer response.
29+
"""
30+
content = json.loads(response.content.decode("utf-8"))
31+
assert "developer_message" in content
32+
return content["developer_message"]
33+
34+
def test_permissions_unauthenticated(self):
35+
"""
36+
Test that an error is returned in the absence of auth credentials.
37+
"""
38+
self.client.logout()
39+
response = self.client.get(self.url)
40+
error = self.get_and_check_developer_response(response)
41+
assert error == "Authentication credentials were not provided."
42+
43+
def test_permissions_unauthorized(self):
44+
"""
45+
Test that an error is returned if the user is unauthorised.
46+
"""
47+
client, _ = self.create_non_staff_authed_user_client()
48+
response = client.get(self.url)
49+
error = self.get_and_check_developer_response(response)
50+
assert error == "You do not have permission to perform this action."
51+
52+
def test_get_course_details(self):
53+
"""
54+
Test for get response
55+
"""
56+
response = self.client.get(self.url)
57+
self.assertEqual(response.status_code, status.HTTP_200_OK)
58+
59+
def test_patch_course_details(self):
60+
"""
61+
Test for patch response
62+
"""
63+
data = {
64+
"start_date": "2030-01-01T00:00:00Z",
65+
"end_date": "2030-01-31T00:00:00Z",
66+
"enrollment_start": "2029-12-01T00:00:00Z",
67+
"enrollment_end": "2030-01-01T00:00:00Z",
68+
"course_title": "Test Course",
69+
"short_description": "This is a test course",
70+
"overview": "This course is for testing purposes",
71+
"intro_video": None
72+
}
73+
response = self.client.patch(self.url, data, content_type='application/json')
74+
self.assertEqual(response.status_code, status.HTTP_200_OK)

cms/djangoapps/contentstore/rest_api/v0/urls.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
from django.urls import re_path
44

55
from openedx.core.constants import COURSE_ID_PATTERN
6-
from .views import AdvancedCourseSettingsView, CourseTabSettingsView, CourseTabListView, CourseTabReorderView
6+
from .views import (
7+
AdvancedCourseSettingsView,
8+
CourseDetailsSettingsView,
9+
CourseTabSettingsView,
10+
CourseTabListView,
11+
CourseTabReorderView
12+
)
713

814
app_name = "v0"
915

@@ -28,4 +34,9 @@
2834
CourseTabReorderView.as_view(),
2935
name="course_tab_reorder",
3036
),
37+
re_path(
38+
fr"^details_settings/{COURSE_ID_PATTERN}$",
39+
CourseDetailsSettingsView.as_view(),
40+
name="course_details_settings",
41+
),
3142
]

cms/djangoapps/contentstore/rest_api/v0/views/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
Views for v0 contentstore API.
33
"""
44
from .advanced_settings import AdvancedCourseSettingsView
5+
from .details_settings import CourseDetailsSettingsView
56
from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
""" API Views for course details settings """
2+
3+
import edx_api_doc_tools as apidocs
4+
from opaque_keys.edx.keys import CourseKey
5+
from rest_framework.request import Request
6+
from rest_framework.views import APIView
7+
from xmodule.modulestore.django import modulestore
8+
9+
from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
10+
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
11+
from common.djangoapps.util.json_request import JsonResponse, expect_json
12+
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
13+
from openedx.core.djangoapps.models.course_details import CourseDetails
14+
15+
from ....views.course import update_course_details_settings
16+
17+
18+
@view_auth_classes(is_authenticated=True)
19+
@expect_json
20+
class CourseDetailsSettingsView(DeveloperErrorViewMixin, APIView):
21+
"""
22+
View for getting and setting the details settings for a course.
23+
"""
24+
25+
@apidocs.schema(
26+
parameters=[
27+
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
28+
],
29+
responses={
30+
401: "The requester is not authenticated.",
31+
403: "The requester cannot access the specified course.",
32+
404: "The requested course does not exist.",
33+
},
34+
)
35+
@verify_course_exists()
36+
def get(self, request: Request, course_id: str):
37+
"""
38+
Get an object containing all the details settings in a course.
39+
"""
40+
course_key = CourseKey.from_string(course_id)
41+
if not has_studio_read_access(request.user, course_key):
42+
self.permission_denied(request)
43+
course_details = CourseDetails.fetch(course_key)
44+
return JsonResponse(
45+
course_details,
46+
# encoder serializes dates, old locations, and instances
47+
encoder=CourseSettingsEncoder
48+
)
49+
50+
@apidocs.schema(
51+
parameters=[
52+
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
53+
],
54+
responses={
55+
401: "The requester is not authenticated.",
56+
403: "The requester cannot access the specified course.",
57+
404: "The requested course does not exist.",
58+
},
59+
)
60+
@verify_course_exists()
61+
def patch(self, request: Request, course_id: str):
62+
"""
63+
Update a course's details settings.
64+
"""
65+
course_key = CourseKey.from_string(course_id)
66+
if not has_studio_write_access(request.user, course_key):
67+
self.permission_denied(request)
68+
course_block = modulestore().get_course(course_key)
69+
return update_course_details_settings(course_key, course_block, request)

cms/djangoapps/contentstore/views/course.py

Lines changed: 63 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,63 +1271,70 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab
12711271
)
12721272
# For every other possible method type submitted by the caller...
12731273
else:
1274-
# if pre-requisite course feature is enabled set pre-requisite course
1275-
if is_prerequisite_courses_enabled():
1276-
prerequisite_course_keys = request.json.get('pre_requisite_courses', [])
1277-
if prerequisite_course_keys:
1278-
if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys):
1279-
return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")})
1280-
set_prerequisite_courses(course_key, prerequisite_course_keys)
1281-
else:
1282-
# None is chosen, so remove the course prerequisites
1283-
course_milestones = milestones_api.get_course_milestones(
1284-
course_key=course_key,
1285-
relationship="requires",
1286-
)
1287-
for milestone in course_milestones:
1288-
entrance_exam_namespace = generate_milestone_namespace(
1289-
get_namespace_choices().get('ENTRANCE_EXAM'),
1290-
course_key
1291-
)
1292-
if milestone["namespace"] != entrance_exam_namespace:
1293-
remove_prerequisite_course(course_key, milestone)
1294-
1295-
# If the entrance exams feature has been enabled, we'll need to check for some
1296-
# feature-specific settings and handle them accordingly
1297-
# We have to be careful that we're only executing the following logic if we actually
1298-
# need to create or delete an entrance exam from the specified course
1299-
if core_toggles.ENTRANCE_EXAMS.is_enabled():
1300-
course_entrance_exam_present = course_block.entrance_exam_enabled
1301-
entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true'
1302-
ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None)
1303-
# If the entrance exam box on the settings screen has been checked...
1304-
if entrance_exam_enabled:
1305-
# Load the default minimum score threshold from settings, then try to override it
1306-
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
1307-
if ee_min_score_pct:
1308-
entrance_exam_minimum_score_pct = float(ee_min_score_pct)
1309-
if entrance_exam_minimum_score_pct.is_integer():
1310-
entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
1311-
# If there's already an entrance exam defined, we'll update the existing one
1312-
if course_entrance_exam_present:
1313-
exam_data = {
1314-
'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct
1315-
}
1316-
update_entrance_exam(request, course_key, exam_data)
1317-
# If there's no entrance exam defined, we'll create a new one
1318-
else:
1319-
create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct)
1320-
1321-
# If the entrance exam box on the settings screen has been unchecked,
1322-
# and the course has an entrance exam attached...
1323-
elif not entrance_exam_enabled and course_entrance_exam_present:
1324-
delete_entrance_exam(request, course_key)
1325-
1326-
# Perform the normal update workflow for the CourseDetails model
1327-
return JsonResponse(
1328-
CourseDetails.update_from_json(course_key, request.json, request.user),
1329-
encoder=CourseSettingsEncoder
1274+
return update_course_details_settings(course_key, course_block, request)
1275+
1276+
1277+
def update_course_details_settings(course_key, course_block: CourseBlock, request):
1278+
"""
1279+
Helper function to update course details settings from API data
1280+
"""
1281+
# if pre-requisite course feature is enabled set pre-requisite course
1282+
if is_prerequisite_courses_enabled():
1283+
prerequisite_course_keys = request.json.get('pre_requisite_courses', [])
1284+
if prerequisite_course_keys:
1285+
if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys):
1286+
return JsonResponseBadRequest({"error": _("Invalid prerequisite course key")})
1287+
set_prerequisite_courses(course_key, prerequisite_course_keys)
1288+
else:
1289+
# None is chosen, so remove the course prerequisites
1290+
course_milestones = milestones_api.get_course_milestones(
1291+
course_key=course_key,
1292+
relationship="requires",
1293+
)
1294+
for milestone in course_milestones:
1295+
entrance_exam_namespace = generate_milestone_namespace(
1296+
get_namespace_choices().get('ENTRANCE_EXAM'),
1297+
course_key
13301298
)
1299+
if milestone["namespace"] != entrance_exam_namespace:
1300+
remove_prerequisite_course(course_key, milestone)
1301+
1302+
# If the entrance exams feature has been enabled, we'll need to check for some
1303+
# feature-specific settings and handle them accordingly
1304+
# We have to be careful that we're only executing the following logic if we actually
1305+
# need to create or delete an entrance exam from the specified course
1306+
if core_toggles.ENTRANCE_EXAMS.is_enabled():
1307+
course_entrance_exam_present = course_block.entrance_exam_enabled
1308+
entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true'
1309+
ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None)
1310+
# If the entrance exam box on the settings screen has been checked...
1311+
if entrance_exam_enabled:
1312+
# Load the default minimum score threshold from settings, then try to override it
1313+
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
1314+
if ee_min_score_pct:
1315+
entrance_exam_minimum_score_pct = float(ee_min_score_pct)
1316+
if entrance_exam_minimum_score_pct.is_integer():
1317+
entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100
1318+
# If there's already an entrance exam defined, we'll update the existing one
1319+
if course_entrance_exam_present:
1320+
exam_data = {
1321+
'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct
1322+
}
1323+
update_entrance_exam(request, course_key, exam_data)
1324+
# If there's no entrance exam defined, we'll create a new one
1325+
else:
1326+
create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct)
1327+
1328+
# If the entrance exam box on the settings screen has been unchecked,
1329+
# and the course has an entrance exam attached...
1330+
elif not entrance_exam_enabled and course_entrance_exam_present:
1331+
delete_entrance_exam(request, course_key)
1332+
1333+
# Perform the normal update workflow for the CourseDetails model
1334+
return JsonResponse(
1335+
CourseDetails.update_from_json(course_key, request.json, request.user),
1336+
encoder=CourseSettingsEncoder
1337+
)
13311338

13321339

13331340
@login_required

common/djangoapps/util/json_request.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from django.core.serializers.json import DjangoJSONEncoder
99
from django.db.models.query import QuerySet
1010
from django.http import HttpResponse, HttpResponseBadRequest
11+
from django.utils.decorators import method_decorator
12+
from django.views import View
1113

1214

1315
class EDXJSONEncoder(DjangoJSONEncoder):
@@ -40,7 +42,6 @@ def expect_json(view_function):
4042
CONTENT_TYPE is application/json, parses the json dict from request.body, and updates
4143
request.POST with the contents.
4244
"""
43-
@wraps(view_function)
4445
def parse_json_into_request(request, *args, **kwargs):
4546
# cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information
4647
# e.g. 'charset', so we can't do a direct string compare
@@ -54,7 +55,14 @@ def parse_json_into_request(request, *args, **kwargs):
5455

5556
return view_function(request, *args, **kwargs)
5657

57-
return parse_json_into_request
58+
if isinstance(view_function, type) and issubclass(view_function, View):
59+
view_function.dispatch = method_decorator(expect_json)(view_function.dispatch)
60+
return view_function
61+
else:
62+
@wraps(view_function)
63+
def wrapper(request, *args, **kwargs):
64+
return parse_json_into_request(request, *args, **kwargs)
65+
return wrapper
5866

5967

6068
class JsonResponse(HttpResponse):

0 commit comments

Comments
 (0)