feat: Create DRF for course grading (#32399)
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Serializers for v1 contentstore API.
|
||||
"""
|
||||
from .settings import CourseSettingsSerializer
|
||||
from .course_details import CourseDetailsSerializer
|
||||
from .grading import CourseGradingModelSerializer, CourseGradingSerializer
|
||||
from .proctoring import (
|
||||
LimitedProctoredExamSettingsSerializer,
|
||||
ProctoredExamConfigurationSerializer,
|
||||
ProctoredExamSettingsSerializer,
|
||||
ProctoringErrorsSerializer,
|
||||
ProctoringErrorsSerializer
|
||||
)
|
||||
from .settings import CourseSettingsSerializer
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
API Serializers for course grading
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GradersSerializer(serializers.Serializer):
|
||||
""" Serializer for graders """
|
||||
type = serializers.CharField()
|
||||
min_count = serializers.IntegerField()
|
||||
drop_count = serializers.IntegerField()
|
||||
short_label = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
weight = serializers.IntegerField()
|
||||
id = serializers.IntegerField()
|
||||
|
||||
|
||||
class GracePeriodSerializer(serializers.Serializer):
|
||||
""" Serializer for course grace period """
|
||||
hours = serializers.IntegerField()
|
||||
minutes = serializers.IntegerField()
|
||||
|
||||
|
||||
class CourseGradingModelSerializer(serializers.Serializer):
|
||||
""" Serializer for course grading model data """
|
||||
graders = GradersSerializer(many=True)
|
||||
grade_cutoffs = serializers.DictField(child=serializers.FloatField())
|
||||
grace_period = GracePeriodSerializer(required=False, allow_null=True)
|
||||
minimum_grade_credit = serializers.FloatField()
|
||||
|
||||
|
||||
class CourseGradingSerializer(serializers.Serializer):
|
||||
""" Serializer for course grading context data """
|
||||
mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
course_assignment_lists = serializers.DictField(
|
||||
child=serializers.ListSerializer(
|
||||
child=serializers.CharField()
|
||||
)
|
||||
)
|
||||
course_details = CourseGradingModelSerializer()
|
||||
show_credit_eligibility = serializers.BooleanField()
|
||||
is_credit_course = serializers.BooleanField()
|
||||
108
cms/djangoapps/contentstore/rest_api/v1/tests/test_grading.py
Normal file
108
cms/djangoapps/contentstore/rest_api/v1/tests/test_grading.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Unit tests for course grading 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 cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from openedx.core.djangoapps.credit.tests.factories import CreditCourseFactory
|
||||
|
||||
from ..mixins import PermissionAccessMixin
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseGradingViewTest(CourseTestCase, PermissionAccessMixin):
|
||||
"""
|
||||
Tests for CourseGradingView.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse(
|
||||
'cms.djangoapps.contentstore:v1:course_grading',
|
||||
kwargs={"course_id": self.course.id},
|
||||
)
|
||||
|
||||
def test_course_grading_response(self):
|
||||
""" Check successful response content """
|
||||
response = self.client.get(self.url)
|
||||
grading_data = CourseGradingModel.fetch(self.course.id)
|
||||
|
||||
expected_response = {
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(self.course.id),
|
||||
'course_assignment_lists': {},
|
||||
'course_details': grading_data.__dict__,
|
||||
'show_credit_eligibility': False,
|
||||
'is_credit_course': 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 enabled values 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.assertTrue(response.data['show_credit_eligibility'])
|
||||
self.assertTrue(response.data['is_credit_course'])
|
||||
|
||||
def test_post_permissions_unauthenticated(self):
|
||||
"""
|
||||
Test that an error is returned in the absence of auth credentials.
|
||||
"""
|
||||
self.client.logout()
|
||||
response = self.client.post(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_post_permissions_unauthorized(self):
|
||||
"""
|
||||
Test that an error is returned if the user is unauthorised.
|
||||
"""
|
||||
client, _ = self.create_non_staff_authed_user_client()
|
||||
response = client.post(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('openedx.core.djangoapps.credit.tasks.update_credit_course_requirements.delay')
|
||||
def test_post_course_grading(self, mock_update_credit_course_requirements):
|
||||
""" Check successful request with called task """
|
||||
request_data = {
|
||||
"graders": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"short_label": "",
|
||||
"weight": 100,
|
||||
"id": 0
|
||||
}
|
||||
],
|
||||
"grade_cutoffs": {
|
||||
"A": 0.75,
|
||||
"B": 0.63,
|
||||
"C": 0.57,
|
||||
"D": 0.5
|
||||
},
|
||||
"grace_period": {
|
||||
"hours": 12,
|
||||
"minutes": 0
|
||||
},
|
||||
"minimum_grade_credit": 0.7,
|
||||
"is_credit_course": True
|
||||
}
|
||||
response = self.client.post(path=self.url, data=json.dumps(request_data), content_type="application/json")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_update_credit_course_requirements.assert_called_once()
|
||||
@@ -6,9 +6,10 @@ from openedx.core.constants import COURSE_ID_PATTERN
|
||||
|
||||
from .views import (
|
||||
CourseDetailsView,
|
||||
CourseGradingView,
|
||||
CourseSettingsView,
|
||||
ProctoredExamSettingsView,
|
||||
ProctoringErrorsView,
|
||||
ProctoringErrorsView
|
||||
)
|
||||
|
||||
app_name = 'v1'
|
||||
@@ -34,4 +35,9 @@ urlpatterns = [
|
||||
CourseDetailsView.as_view(),
|
||||
name="course_details"
|
||||
),
|
||||
re_path(
|
||||
fr'^course_grading/{COURSE_ID_PATTERN}$',
|
||||
CourseGradingView.as_view(),
|
||||
name="course_grading"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
Views for v1 contentstore API.
|
||||
"""
|
||||
from .course_details import CourseDetailsView
|
||||
from .settings import CourseSettingsView
|
||||
from .grading import CourseGradingView
|
||||
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView
|
||||
from .settings import CourseSettingsView
|
||||
|
||||
174
cms/djangoapps/contentstore/rest_api/v1/views/grading.py
Normal file
174
cms/djangoapps/contentstore/rest_api/v1/views/grading.py
Normal file
@@ -0,0 +1,174 @@
|
||||
""" API Views for course grading """
|
||||
|
||||
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 cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from common.djangoapps.student.auth import has_studio_read_access
|
||||
from openedx.core.djangoapps.credit.api import is_credit_course
|
||||
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from ..serializers import CourseGradingModelSerializer, CourseGradingSerializer
|
||||
from ....utils import get_course_grading
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class CourseGradingView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
View for Course Grading policy configuration.
|
||||
"""
|
||||
|
||||
@apidocs.schema(
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: CourseGradingSerializer,
|
||||
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 course grading settings with model.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/contentstore/v1/course_grading/{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 grading.
|
||||
|
||||
**Example Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"mfe_proctored_exam_settings_url": "",
|
||||
"course_assignment_lists": {
|
||||
"Homework": [
|
||||
"Section :754c5e889ac3489e9947ba62b916bdab - Subsection :56c1bc20d270414b877e9c178954b6ed"
|
||||
]
|
||||
},
|
||||
"course_details": {
|
||||
"graders": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"short_label": "",
|
||||
"weight": 100,
|
||||
"id": 0
|
||||
}
|
||||
],
|
||||
"grade_cutoffs": {
|
||||
"A": 0.75,
|
||||
"B": 0.63,
|
||||
"C": 0.57,
|
||||
"D": 0.5
|
||||
},
|
||||
"grace_period": {
|
||||
"hours": 12,
|
||||
"minutes": 0
|
||||
},
|
||||
"minimum_grade_credit": 0.7
|
||||
},
|
||||
"show_credit_eligibility": false,
|
||||
"is_credit_course": true
|
||||
}
|
||||
```
|
||||
"""
|
||||
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):
|
||||
credit_eligibility_enabled = settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False)
|
||||
show_credit_eligibility = is_credit_course(course_key) and credit_eligibility_enabled
|
||||
|
||||
grading_context = get_course_grading(course_key)
|
||||
grading_context['show_credit_eligibility'] = show_credit_eligibility
|
||||
|
||||
serializer = CourseGradingSerializer(grading_context)
|
||||
return Response(serializer.data)
|
||||
|
||||
@apidocs.schema(
|
||||
body=CourseGradingModelSerializer,
|
||||
parameters=[
|
||||
apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"),
|
||||
],
|
||||
responses={
|
||||
200: CourseGradingModelSerializer,
|
||||
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 post(self, request: Request, course_id: str):
|
||||
"""
|
||||
Update a course's grading.
|
||||
|
||||
**Example Request**
|
||||
|
||||
PUT /api/contentstore/v1/course_grading/{course_id}
|
||||
|
||||
**POST Parameters**
|
||||
|
||||
The data sent for a post request should follow next object.
|
||||
Here is an example request data that updates the ``course_grading``
|
||||
|
||||
```json
|
||||
{
|
||||
"graders": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"short_label": "",
|
||||
"weight": 100,
|
||||
"id": 0
|
||||
}
|
||||
],
|
||||
"grade_cutoffs": {
|
||||
"A": 0.75,
|
||||
"B": 0.63,
|
||||
"C": 0.57,
|
||||
"D": 0.5
|
||||
},
|
||||
"grace_period": {
|
||||
"hours": 12,
|
||||
"minutes": 0
|
||||
},
|
||||
"minimum_grade_credit": 0.7,
|
||||
"is_credit_course": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Values**
|
||||
|
||||
If the request is successful, an HTTP 200 "OK" response is returned,
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
self.permission_denied(request)
|
||||
|
||||
if 'minimum_grade_credit' in request.data:
|
||||
update_credit_course_requirements.delay(str(course_key))
|
||||
|
||||
updated_data = CourseGradingModel.update_from_json(course_key, request.data, request.user)
|
||||
serializer = CourseGradingModelSerializer(updated_data)
|
||||
return Response(serializer.data)
|
||||
@@ -72,6 +72,7 @@ from cms.djangoapps.contentstore.toggles import (
|
||||
use_new_video_uploads_page,
|
||||
)
|
||||
from cms.djangoapps.contentstore.toggles import use_new_text_editor, use_new_video_editor
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
@@ -1309,6 +1310,28 @@ def get_course_settings(request, course_key, course_block):
|
||||
return settings_context
|
||||
|
||||
|
||||
def get_course_grading(course_key):
|
||||
"""
|
||||
Utils is used to get context of course grading.
|
||||
It is used for both DRF and django views.
|
||||
"""
|
||||
|
||||
course_block = modulestore().get_course(course_key)
|
||||
course_details = CourseGradingModel.fetch(course_key)
|
||||
course_assignment_lists = get_subsections_by_assignment_type(course_key)
|
||||
grading_context = {
|
||||
'context_course': course_block,
|
||||
'course_locator': course_key,
|
||||
'course_details': course_details,
|
||||
'grading_url': reverse_course_url('grading_handler', course_key),
|
||||
'is_credit_course': is_credit_course(course_key),
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_key),
|
||||
'course_assignment_lists': dict(course_assignment_lists)
|
||||
}
|
||||
|
||||
return grading_context
|
||||
|
||||
|
||||
class StudioPermissionsService:
|
||||
"""
|
||||
Service that can provide information about a user's permissions.
|
||||
@@ -1317,6 +1340,7 @@ class StudioPermissionsService:
|
||||
|
||||
Only used by LibraryContentBlock (and library_tools.py).
|
||||
"""
|
||||
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadReq
|
||||
from common.djangoapps.util.string_utils import _has_non_ascii_characters
|
||||
from common.djangoapps.xblock_django.api import deprecated_xblocks
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
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
|
||||
@@ -90,9 +89,9 @@ from ..toggles import split_library_view_on_dashboard
|
||||
from ..utils import (
|
||||
add_instructor,
|
||||
get_course_settings,
|
||||
get_course_grading,
|
||||
get_lms_link_for_item,
|
||||
get_proctored_exam_settings_url,
|
||||
get_subsections_by_assignment_type,
|
||||
initialize_permissions,
|
||||
remove_all_instructors,
|
||||
reverse_course_url,
|
||||
@@ -1186,20 +1185,12 @@ def grading_handler(request, course_key_string, grader_index=None):
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
with modulestore().bulk_operations(course_key):
|
||||
course_block = get_course_and_check_access(course_key, request.user)
|
||||
if not has_studio_read_access(request.user, course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
|
||||
course_details = CourseGradingModel.fetch(course_key)
|
||||
course_assignment_lists = get_subsections_by_assignment_type(course_key)
|
||||
return render_to_response('settings_graders.html', {
|
||||
'context_course': course_block,
|
||||
'course_locator': course_key,
|
||||
'course_details': course_details,
|
||||
'grading_url': reverse_course_url('grading_handler', course_key),
|
||||
'is_credit_course': is_credit_course(course_key),
|
||||
'mfe_proctored_exam_settings_url': get_proctored_exam_settings_url(course_block.id),
|
||||
'course_assignment_lists': dict(course_assignment_lists)
|
||||
})
|
||||
grading_context = get_course_grading(course_key)
|
||||
return render_to_response('settings_graders.html', grading_context)
|
||||
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
|
||||
if request.method == 'GET':
|
||||
if grader_index is None:
|
||||
|
||||
Reference in New Issue
Block a user