Add an /api/courses/v1/grading endpoint to get assignment type and subsection info about a course.

This commit is contained in:
Alex Dusenbery
2018-11-19 17:27:15 -05:00
committed by Alex Dusenbery
parent 30eb003b2e
commit 009074ec4b
12 changed files with 511 additions and 98 deletions

View File

@@ -0,0 +1,93 @@
"""
Base test case for the course API views.
"""
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from lms.djangoapps.courseware.tests.factories import StaffFactory
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
# pylint: disable=unused-variable
class BaseCourseViewTest(SharedModuleStoreTestCase, APITestCase):
"""
Base test class for course data views.
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
view_name = None # The name of the view to use in reverse() call in self.get_url()
@classmethod
def setUpClass(cls):
super(BaseCourseViewTest, cls).setUpClass()
cls.course = CourseFactory.create(display_name='test course', run="Testing_course")
cls.course_key = cls.course.id
cls.password = 'test'
cls.student = UserFactory(username='dummy', password=cls.password)
cls.staff = StaffFactory(course_key=cls.course.id, password=cls.password)
cls.initialize_course(cls.course)
@classmethod
def initialize_course(cls, course):
"""
Sets up the structure of the test course.
"""
course.self_paced = True
cls.store.update_item(course, cls.staff.id)
cls.section = ItemFactory.create(
parent_location=course.location,
category="chapter",
)
cls.subsection1 = ItemFactory.create(
parent_location=cls.section.location,
category="sequential",
)
unit1 = ItemFactory.create(
parent_location=cls.subsection1.location,
category="vertical",
)
ItemFactory.create(
parent_location=unit1.location,
category="video",
)
ItemFactory.create(
parent_location=unit1.location,
category="problem",
)
cls.subsection2 = ItemFactory.create(
parent_location=cls.section.location,
category="sequential",
)
unit2 = ItemFactory.create(
parent_location=cls.subsection2.location,
category="vertical",
)
unit3 = ItemFactory.create(
parent_location=cls.subsection2.location,
category="vertical",
)
ItemFactory.create(
parent_location=unit3.location,
category="video",
)
ItemFactory.create(
parent_location=unit3.location,
category="video",
)
def get_url(self, course_id):
"""
Helper function to create the url
"""
return reverse(
self.view_name,
kwargs={
'course_id': course_id
}
)

View File

@@ -0,0 +1,159 @@
"""
Tests for the course grading API view
"""
from rest_framework import status
from six import text_type
from xmodule.modulestore.tests.factories import ItemFactory
from .base import BaseCourseViewTest
class CourseGradingViewTest(BaseCourseViewTest):
"""
Test course grading view via a RESTful API
"""
view_name = 'courses_api:course_grading'
@classmethod
def setUpClass(cls):
super(CourseGradingViewTest, cls).setUpClass()
cls.homework = ItemFactory.create(
parent_location=cls.section.location,
category="sequential",
graded=True,
format='Homework',
)
cls.midterm = ItemFactory.create(
parent_location=cls.section.location,
category="sequential",
graded=True,
format='Midterm Exam',
)
def test_student_fails(self):
self.client.login(username=self.student.username, password=self.password)
resp = self.client.get(self.get_url(self.course_key))
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
def test_staff_succeeds(self):
self.client.login(username=self.staff.username, password=self.password)
resp = self.client.get(self.get_url(self.course_key))
self.assertEqual(resp.status_code, status.HTTP_200_OK)
expected_data = {
'assignment_types': {
'Final Exam': {
'drop_count': 0,
'min_count': 1,
'short_label': 'Final',
'type': 'Final Exam',
'weight': 0.4
},
'Homework': {
'drop_count': 2,
'min_count': 12,
'short_label': 'HW',
'type': 'Homework',
'weight': 0.15
},
'Lab': {
'drop_count': 2,
'min_count': 12,
'short_label': 'Lab',
'type': 'Lab',
'weight': 0.15
},
'Midterm Exam': {
'drop_count': 0,
'min_count': 1,
'short_label': 'Midterm',
'type': 'Midterm Exam',
'weight': 0.3
}
},
'subsections': [
{
'assignment_type': None,
'display_name': self.subsection1.display_name,
'graded': False,
'module_id': text_type(self.subsection1.location),
'short_label': None
},
{
'assignment_type': None,
'display_name': self.subsection2.display_name,
'graded': False,
'module_id': text_type(self.subsection2.location),
'short_label': None
},
{
'assignment_type': 'Homework',
'display_name': self.homework.display_name,
'graded': True,
'module_id': text_type(self.homework.location),
'short_label': 'HW 01',
},
{
'assignment_type': 'Midterm Exam',
'display_name': self.midterm.display_name,
'graded': True,
'module_id': text_type(self.midterm.location),
'short_label': 'Midterm 01',
},
]
}
self.assertEqual(expected_data, resp.data)
def test_staff_succeeds_graded_only(self):
self.client.login(username=self.staff.username, password=self.password)
resp = self.client.get(self.get_url(self.course_key), {'graded_only': True})
self.assertEqual(resp.status_code, status.HTTP_200_OK)
expected_data = {
'assignment_types': {
'Final Exam': {
'drop_count': 0,
'min_count': 1,
'short_label': 'Final',
'type': 'Final Exam',
'weight': 0.4
},
'Homework': {
'drop_count': 2,
'min_count': 12,
'short_label': 'HW',
'type': 'Homework',
'weight': 0.15
},
'Lab': {
'drop_count': 2,
'min_count': 12,
'short_label': 'Lab',
'type': 'Lab',
'weight': 0.15
},
'Midterm Exam': {
'drop_count': 0,
'min_count': 1,
'short_label': 'Midterm',
'type': 'Midterm Exam',
'weight': 0.3
}
},
'subsections': [
{
'assignment_type': 'Homework',
'display_name': self.homework.display_name,
'graded': True,
'module_id': text_type(self.homework.location),
'short_label': 'HW 01',
},
{
'assignment_type': 'Midterm Exam',
'display_name': self.midterm.display_name,
'graded': True,
'module_id': text_type(self.midterm.location),
'short_label': 'Midterm 01',
},
]
}
self.assertEqual(expected_data, resp.data)

View File

@@ -1,97 +1,16 @@
"""
Tests for the course import API views
"""
from django.core.urlresolvers import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from lms.djangoapps.courseware.tests.factories import StaffFactory
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from .base import BaseCourseViewTest
class CourseQualityViewTest(SharedModuleStoreTestCase, APITestCase):
class CourseQualityViewTest(BaseCourseViewTest):
"""
Test course quality view via a RESTful API
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
@classmethod
def setUpClass(cls):
super(CourseQualityViewTest, cls).setUpClass()
cls.course = CourseFactory.create(display_name='test course', run="Testing_course")
cls.course_key = cls.course.id
cls.password = 'test'
cls.student = UserFactory(username='dummy', password=cls.password)
cls.staff = StaffFactory(course_key=cls.course.id, password=cls.password)
cls.initialize_course(cls.course)
@classmethod
def initialize_course(cls, course):
course.self_paced = True
cls.store.update_item(course, cls.staff.id)
section = ItemFactory.create(
parent_location=course.location,
category="chapter",
)
subsection1 = ItemFactory.create(
parent_location=section.location,
category="sequential",
)
unit1 = ItemFactory.create(
parent_location=subsection1.location,
category="vertical",
)
ItemFactory.create(
parent_location=unit1.location,
category="video",
)
ItemFactory.create(
parent_location=unit1.location,
category="problem",
)
subsection2 = ItemFactory.create(
parent_location=section.location,
category="sequential",
)
unit2 = ItemFactory.create(
parent_location=subsection2.location,
category="vertical",
)
unit3 = ItemFactory.create(
parent_location=subsection2.location,
category="vertical",
)
ItemFactory.create(
parent_location=unit3.location,
category="video",
)
ItemFactory.create(
parent_location=unit3.location,
category="video",
)
def get_url(self, course_id):
"""
Helper function to create the url
"""
return reverse(
'courses_api:course_quality',
kwargs={
'course_id': course_id
}
)
def test_student_fails(self):
self.client.login(username=self.student.username, password=self.password)
resp = self.client.get(self.get_url(self.course_key))
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
view_name = 'courses_api:course_quality'
def test_staff_succeeds(self):
self.client.login(username=self.staff.username, password=self.password)
@@ -141,3 +60,8 @@ class CourseQualityViewTest(SharedModuleStoreTestCase, APITestCase):
'is_self_paced': True,
}
self.assertDictEqual(resp.data, expected_data)
def test_student_fails(self):
self.client.login(username=self.student.username, password=self.password)
resp = self.client.get(self.get_url(self.course_key))
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)

View File

@@ -33,6 +33,9 @@ class CourseValidationViewTest(SharedModuleStoreTestCase, APITestCase):
@classmethod
def initialize_course(cls, course):
"""
Sets up test course structure.
"""
course.start = datetime.now()
course.self_paced = True
cls.store.update_item(course, cls.staff.id)

View File

@@ -2,7 +2,10 @@
from django.conf import settings
from django.conf.urls import url
from cms.djangoapps.contentstore.api.views import course_import, course_validation, course_quality
from cms.djangoapps.contentstore.api.views import course_grading, course_import, course_quality, course_validation
app_name = 'contentstore'
urlpatterns = [
url(r'^v0/import/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN,),
@@ -11,4 +14,6 @@ urlpatterns = [
course_validation.CourseValidationView.as_view(), name='course_validation'),
url(r'^v1/quality/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN,),
course_quality.CourseQualityView.as_view(), name='course_quality'),
url(r'^v1/grading/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN,),
course_grading.CourseGradingView.as_view(), name='course_grading'),
]

View File

@@ -0,0 +1,101 @@
"""
Defines an endpoint for retrieving assignment type and subsection info for a course.
"""
from rest_framework.response import Response
from six import text_type
from xmodule.util.misc import get_default_short_labeler
from .utils import BaseCourseView, course_author_access_required, get_bool_param
class CourseGradingView(BaseCourseView):
"""
Returns information about assignments and assignment types for a course.
**Example Requests**
GET /api/courses/v1/grading/{course_id}/
**GET Parameters**
A GET request may include the following parameters.
* graded_only (boolean) - If true, only returns subsection data for graded subsections (defaults to False).
**GET Response Values**
The HTTP 200 response has the following values.
* assignment_types - A dictionary keyed by the assignment type name with the following values:
* min_count - The minimum number of required assignments of this type.
* weight - The weight assigned to this assignment type for course grading.
* type - The name of the assignment type.
* drop_count - The maximum number of assignments of this type that can be dropped.
* short_label - The short label prefix used for short labels of assignments of this type (e.g. 'HW').
* subsections - A list of subsections contained in this course.
* module_id - The string version of this subsection's location.
* display_name - The display name of this subsection.
* graded - Boolean indicating whether this subsection is graded (for at least one user in the course).
* short_label - A short label for graded assignments (e.g. 'HW 01').
* assignment_type - The assignment type of this subsection (for graded assignments only).
"""
@course_author_access_required
def get(self, request, course_key):
"""
Returns grading information (which subsections are graded, assignment types) for
the requested course.
"""
graded_only = get_bool_param(request, 'graded_only', False)
with self.get_course(request, course_key) as course:
results = {
'assignment_types': self._get_assignment_types(course),
'subsections': self._get_subsections(course, graded_only),
}
return Response(results)
def _get_assignment_types(self, course):
"""
Helper function that returns a serialized dict of assignment types
for the given course.
Args:
course - A course object.
"""
serialized_grading_policies = {}
for grader, assignment_type, weight in course.grader.subgraders:
serialized_grading_policies[assignment_type] = {
'type': assignment_type,
'short_label': grader.short_label,
'min_count': grader.min_count,
'drop_count': grader.drop_count,
'weight': weight,
}
return serialized_grading_policies
def _get_subsections(self, course, graded_only=False):
"""
Helper function that returns a list of subsections contained in the given course.
Args:
course - A course object.
graded_only - If true, returns only graded subsections (defaults to False).
"""
subsections = []
short_labeler = get_default_short_labeler(course)
for subsection in self._get_visible_subsections(course):
if graded_only and not subsection.graded:
continue
short_label = None
if subsection.graded:
short_label = short_labeler(subsection.format)
subsections.append({
'assignment_type': subsection.format,
'graded': subsection.graded,
'short_label': short_label,
'module_id': text_type(subsection.location),
'display_name': subsection.display_name,
})
return subsections

View File

@@ -1,15 +1,103 @@
"""
Common utilities for Contentstore APIs.
"""
from rest_framework import status
from contextlib import contextmanager
from rest_framework import status
from rest_framework.generics import GenericAPIView
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.util.forms import to_bool
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from openedx.core.lib.cache_utils import request_cached
from student.auth import has_course_author_access
from xmodule.modulestore.django import modulestore
@view_auth_classes()
class BaseCourseView(DeveloperErrorViewMixin, GenericAPIView):
"""
A base class for contentstore course api views.
"""
@contextmanager
def get_course(self, request, course_key):
"""
Context manager that yields a course, given a request and course_key.
"""
store = modulestore()
with store.bulk_operations(course_key):
course = store.get_course(course_key, depth=self._required_course_depth(request))
yield course
@staticmethod
def _required_course_depth(request):
"""
Returns how far deep we need to go into the course tree to
get all of the information required. Will use entire tree if the request's
`all` param is truthy, otherwise goes to depth of 2 (subsections).
"""
all_requested = get_bool_param(request, 'all', False)
if all_requested:
return None
return 2
@classmethod
@request_cached()
def _get_visible_subsections(cls, course):
"""
Returns a list of all visible subsections for a course.
"""
_, visible_sections = cls._get_sections(course)
visible_subsections = []
for section in visible_sections:
visible_subsections.extend(cls._get_visible_children(section))
return visible_subsections
@classmethod
@request_cached()
def _get_sections(cls, course):
"""
Returns all sections in the course.
"""
return cls._get_all_children(course)
@classmethod
def _get_all_children(cls, parent):
"""
Returns all child nodes of the given parent.
"""
store = modulestore()
children = [store.get_item(child_usage_key) for child_usage_key in cls._get_children(parent)]
visible_children = [
c for c in children
if not c.visible_to_staff_only and not c.hide_from_toc
]
return children, visible_children
@classmethod
def _get_visible_children(cls, parent):
"""
Returns only the visible children of the given parent.
"""
_, visible_chidren = cls._get_all_children(parent)
return visible_chidren
@classmethod
def _get_children(cls, parent):
"""
Returns the value of the 'children' attribute of a node.
"""
if not hasattr(parent, 'children'):
return []
else:
return parent.children
def get_bool_param(request, param_name, default):
"""
Given a request, parameter name, and default value, returns
either a boolean value or the default.
"""
param_value = request.query_params.get(param_name, None)
bool_value = to_bool(param_value)
if bool_value is None: