Skip to content

Commit

Permalink
feat: Create DRF for course grading (openedx#32399)
Browse files Browse the repository at this point in the history
(cherry picked from commit dea67f2)
  • Loading branch information
ruzniaievdm authored and kaustavb12 committed Jul 10, 2023
1 parent 6b01531 commit 6f697eb
Show file tree
Hide file tree
Showing 7 changed files with 387 additions and 13 deletions.
42 changes: 42 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/mixins.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 35 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,38 @@ class ProctoredExamConfigurationSerializer(serializers.Serializer):
proctored_exam_settings = ProctoredExamSettingsSerializer()
available_proctoring_providers = serializers.ChoiceField(get_available_providers())
course_start_date = serializers.DateTimeField()


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_details = CourseGradingModelSerializer()
show_credit_eligibility = serializers.BooleanField()
is_credit_course = serializers.BooleanField()
default_grade_designations = serializers.ListSerializer(
child=serializers.CharField()
)
108 changes: 108 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/tests/test_grading.py
Original file line number Diff line number Diff line change
@@ -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_details': grading_data.__dict__,
'show_credit_eligibility': False,
'is_credit_course': False,
'default_grade_designations': ["A", "B", "C", "D"]
}

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()
5 changes: 5 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,9 @@
views.ProctoredExamSettingsView.as_view(),
name="proctored_exam_settings"
),
re_path(
fr'^course_grading/{COURSE_ID_PATTERN}$',
views.CourseGradingView.as_view(),
name="course_grading"
),
]
171 changes: 169 additions & 2 deletions cms/djangoapps/contentstore/rest_api/v1/views.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
"Contentstore Views"
import copy

import edx_api_doc_tools as apidocs
from django.conf import settings
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
from rest_framework.exceptions import NotFound
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView

from cms.djangoapps.contentstore.views.course import get_course_and_check_access
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from common.djangoapps.student.auth import has_studio_read_access
from xmodule.course_block import get_available_providers # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled
from openedx.core.lib.api.view_utils import view_auth_classes
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 # lint-amnesty, pylint: disable=wrong-import-order

from .serializers import (
LimitedProctoredExamSettingsSerializer,
ProctoredExamConfigurationSerializer,
ProctoredExamSettingsSerializer
ProctoredExamSettingsSerializer,
CourseGradingModelSerializer,
CourseGradingSerializer
)

from ...utils import get_course_grading


@view_auth_classes()
class ProctoredExamSettingsView(APIView):
Expand Down Expand Up @@ -182,3 +193,159 @@ def _get_and_validate_course_access(user, course_id):
)

return course_block


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

0 comments on commit 6f697eb

Please sign in to comment.