Skip to content

Commit

Permalink
feat: Extend settings handler to be accessible via api (#533)
Browse files Browse the repository at this point in the history
  • Loading branch information
pkulkark authored May 18, 2023
1 parent b8e9d1e commit bca8d34
Show file tree
Hide file tree
Showing 6 changed files with 222 additions and 52 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""
Tests for the course advanced settings API.
"""
import json

import ddt
from django.urls import reverse
from rest_framework import status

from cms.djangoapps.contentstore.tests.utils import CourseTestCase


@ddt.ddt
class CourseDetailsSettingViewTest(CourseTestCase):
"""
Tests for DetailsSettings API View.
"""

def setUp(self):
super().setUp()
self.url = reverse(
"cms.djangoapps.contentstore:v0:course_details_settings",
kwargs={"course_id": self.course.id},
)

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."

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)
assert error == "You do not have permission to perform this action."

def test_get_course_details(self):
"""
Test for get response
"""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_patch_course_details(self):
"""
Test for patch response
"""
data = {
"start_date": "2030-01-01T00:00:00Z",
"end_date": "2030-01-31T00:00:00Z",
"enrollment_start": "2029-12-01T00:00:00Z",
"enrollment_end": "2030-01-01T00:00:00Z",
"course_title": "Test Course",
"short_description": "This is a test course",
"overview": "This course is for testing purposes",
"intro_video": None
}
response = self.client.patch(self.url, data, content_type='application/json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
13 changes: 12 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
from django.urls import re_path

from openedx.core.constants import COURSE_ID_PATTERN
from .views import AdvancedCourseSettingsView, CourseTabSettingsView, CourseTabListView, CourseTabReorderView
from .views import (
AdvancedCourseSettingsView,
CourseDetailsSettingsView,
CourseTabSettingsView,
CourseTabListView,
CourseTabReorderView
)

app_name = "v0"

Expand All @@ -28,4 +34,9 @@
CourseTabReorderView.as_view(),
name="course_tab_reorder",
),
re_path(
fr"^details_settings/{COURSE_ID_PATTERN}$",
CourseDetailsSettingsView.as_view(),
name="course_details_settings",
),
]
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
Views for v0 contentstore API.
"""
from .advanced_settings import AdvancedCourseSettingsView
from .details_settings import CourseDetailsSettingsView
from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView
69 changes: 69 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v0/views/details_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
""" API Views for course details settings """

import edx_api_doc_tools as apidocs
from opaque_keys.edx.keys import CourseKey
from rest_framework.request import Request
from rest_framework.views import APIView
from xmodule.modulestore.django import modulestore

from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
from common.djangoapps.util.json_request import JsonResponse, expect_json
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
from openedx.core.djangoapps.models.course_details import CourseDetails

from ....views.course import update_course_details_settings


@view_auth_classes(is_authenticated=True)
@expect_json
class CourseDetailsSettingsView(DeveloperErrorViewMixin, APIView):
"""
View for getting and setting the details settings for a course.
"""

@apidocs.schema(
parameters=[
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
],
responses={
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 details settings in a course.
"""
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)
return JsonResponse(
course_details,
# encoder serializes dates, old locations, and instances
encoder=CourseSettingsEncoder
)

@apidocs.schema(
parameters=[
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
],
responses={
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 patch(self, request: Request, course_id: str):
"""
Update a course's details settings.
"""
course_key = CourseKey.from_string(course_id)
if not has_studio_write_access(request.user, course_key):
self.permission_denied(request)
course_block = modulestore().get_course(course_key)
return update_course_details_settings(course_key, course_block, request)
105 changes: 56 additions & 49 deletions cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -1241,55 +1241,62 @@ 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") # lint-amnesty, pylint: disable=line-too-long
for milestone in course_milestones:
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_module.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 update_course_details_settings(course_key, course_module, request)


def update_course_details_settings(course_key, course_module: CourseBlock, request):
"""
Helper function to update course details settings from API data
"""
# 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") # lint-amnesty, pylint: disable=line-too-long
for milestone in course_milestones:
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_module.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
)


@login_required
Expand Down
12 changes: 10 additions & 2 deletions common/djangoapps/util/json_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models.query import QuerySet
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils.decorators import method_decorator
from django.views import View


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

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

return parse_json_into_request
if isinstance(view_function, type) and issubclass(view_function, View):
view_function.dispatch = method_decorator(expect_json)(view_function.dispatch)
return view_function
else:
@wraps(view_function)
def wrapper(request, *args, **kwargs):
return parse_json_into_request(request, *args, **kwargs)
return wrapper


class JsonResponse(HttpResponse):
Expand Down

0 comments on commit bca8d34

Please sign in to comment.