feat: Create DRF for course settings and course details views out of current Django views (#32397)

This commit is contained in:
ruzniaievdm
2023-06-08 18:07:29 +03:00
committed by GitHub
parent 47a568adca
commit 96b8ba5d6a
16 changed files with 841 additions and 196 deletions

View 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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -1,5 +1,5 @@
"""
API Serializers for Contentstore
API Serializers for proctoring
"""
from rest_framework import serializers

View File

@@ -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()

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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"
),
]

View File

@@ -0,0 +1,6 @@
"""
Views for v1 contentstore API.
"""
from .course_details import CourseDetailsView
from .settings import CourseSettingsView
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView

View 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)

View File

@@ -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,

View 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)

View File

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

View File

@@ -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

View File

@@ -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]