feat: Create DRF for course grading (#32399)

This commit is contained in:
ruzniaievdm
2023-06-09 17:52:30 +03:00
committed by GitHub
parent 2e447b043d
commit dea67f29c4
8 changed files with 365 additions and 18 deletions

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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