Course Blocks API
This commit is contained in:
committed by
J. Cliff Dyer
parent
cbf90677b7
commit
00e9237153
1
lms/djangoapps/course_api/__init__.py
Normal file
1
lms/djangoapps/course_api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
""" Course API """
|
||||
3
lms/djangoapps/course_api/blocks/__init__.py
Normal file
3
lms/djangoapps/course_api/blocks/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Course API Blocks
|
||||
"""
|
||||
55
lms/djangoapps/course_api/blocks/api.py
Normal file
55
lms/djangoapps/course_api/blocks/api.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
API function for retrieving course blocks data
|
||||
"""
|
||||
|
||||
from .transformers.blocks_api import BlocksAPITransformer
|
||||
from .transformers.proctored_exam import ProctoredExamTransformer
|
||||
from .serializers import BlockSerializer, BlockDictSerializer
|
||||
from lms.djangoapps.course_blocks.api import get_course_blocks, COURSE_BLOCK_ACCESS_TRANSFORMERS
|
||||
|
||||
|
||||
def get_blocks(
|
||||
request,
|
||||
usage_key,
|
||||
user=None,
|
||||
depth=None,
|
||||
nav_depth=None,
|
||||
requested_fields=None,
|
||||
block_counts=None,
|
||||
student_view_data=None,
|
||||
return_type='dict'
|
||||
):
|
||||
"""
|
||||
Return a serialized representation of the course blocks
|
||||
"""
|
||||
# TODO support user=None by returning all blocks, not just user-specific ones
|
||||
if user is None:
|
||||
raise NotImplementedError
|
||||
|
||||
# transform blocks
|
||||
blocks_api_transformer = BlocksAPITransformer(
|
||||
block_counts,
|
||||
student_view_data,
|
||||
depth,
|
||||
nav_depth
|
||||
)
|
||||
blocks = get_course_blocks(
|
||||
user,
|
||||
usage_key,
|
||||
transformers=COURSE_BLOCK_ACCESS_TRANSFORMERS + [ProctoredExamTransformer(), blocks_api_transformer],
|
||||
)
|
||||
|
||||
# serialize
|
||||
serializer_context = {
|
||||
'request': request,
|
||||
'block_structure': blocks,
|
||||
'requested_fields': requested_fields or [],
|
||||
}
|
||||
|
||||
if return_type == 'dict':
|
||||
serializer = BlockDictSerializer(blocks, context=serializer_context, many=False)
|
||||
else:
|
||||
serializer = BlockSerializer(blocks, context=serializer_context, many=True)
|
||||
|
||||
# return serialized data
|
||||
return serializer.data
|
||||
134
lms/djangoapps/course_api/blocks/forms.py
Normal file
134
lms/djangoapps/course_api/blocks/forms.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Course API Forms
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import Form, CharField, ChoiceField, IntegerField
|
||||
from django.http import Http404
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from openedx.core.djangoapps.util.forms import MultiValueField
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from .permissions import can_access_other_users_blocks, can_access_users_blocks
|
||||
|
||||
|
||||
class BlockListGetForm(Form):
|
||||
"""
|
||||
A form to validate query parameters in the block list retrieval endpoint
|
||||
"""
|
||||
username = CharField(required=True) # TODO return all blocks if user is not specified by requesting staff user
|
||||
usage_key = CharField(required=True)
|
||||
requested_fields = MultiValueField(required=False)
|
||||
student_view_data = MultiValueField(required=False)
|
||||
block_counts = MultiValueField(required=False)
|
||||
depth = CharField(required=False)
|
||||
nav_depth = IntegerField(required=False, min_value=0)
|
||||
return_type = ChoiceField(
|
||||
required=False,
|
||||
choices=[(choice, choice) for choice in ['dict', 'list']],
|
||||
)
|
||||
|
||||
def clean_requested_fields(self):
|
||||
"""
|
||||
Return a set of `requested_fields`, merged with defaults of `type`
|
||||
and `display_name`
|
||||
"""
|
||||
requested_fields = self.cleaned_data['requested_fields']
|
||||
|
||||
# add default requested_fields
|
||||
return (requested_fields or set()) | {'type', 'display_name'}
|
||||
|
||||
def clean_depth(self):
|
||||
"""
|
||||
Get the appropriate depth. No provided value will be treated as a
|
||||
depth of 0, while a value of "all" will be treated as unlimited depth.
|
||||
"""
|
||||
value = self.cleaned_data['depth']
|
||||
if not value:
|
||||
return 0
|
||||
elif value == "all":
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValidationError("'{}' is not a valid depth value.".format(value))
|
||||
|
||||
def clean_usage_key(self):
|
||||
"""
|
||||
Ensure a valid `usage_key` was provided.
|
||||
"""
|
||||
usage_key = self.cleaned_data['usage_key']
|
||||
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key)
|
||||
usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
|
||||
except InvalidKeyError:
|
||||
raise ValidationError("'{}' is not a valid usage key.".format(unicode(usage_key)))
|
||||
|
||||
return usage_key
|
||||
|
||||
def clean_return_type(self):
|
||||
"""
|
||||
Return valid 'return_type' or default value of 'dict'
|
||||
"""
|
||||
return self.cleaned_data['return_type'] or 'dict'
|
||||
|
||||
def clean_requested_user(self, cleaned_data, course_key):
|
||||
"""
|
||||
Validates and returns the requested_user, while checking permissions.
|
||||
"""
|
||||
requested_username = cleaned_data.get('username', '')
|
||||
requesting_user = self.initial['requesting_user']
|
||||
|
||||
if requesting_user.username.lower() == requested_username.lower():
|
||||
requested_user = requesting_user
|
||||
else:
|
||||
# the requesting user is trying to access another user's view
|
||||
# verify requesting user can access another user's blocks
|
||||
if not can_access_other_users_blocks(requesting_user, course_key):
|
||||
raise PermissionDenied(
|
||||
"'{requesting_username}' does not have permission to access view for '{requested_username}'."
|
||||
.format(requesting_username=requesting_user.username, requested_username=requested_username)
|
||||
)
|
||||
|
||||
# update requested user object
|
||||
try:
|
||||
requested_user = User.objects.get(username=requested_username)
|
||||
except User.DoesNotExist:
|
||||
raise Http404("Requested user '{username}' does not exist.".format(username=requested_username))
|
||||
|
||||
# verify whether the requested user's blocks can be accessed
|
||||
if not can_access_users_blocks(requested_user, course_key):
|
||||
raise PermissionDenied(
|
||||
"Course blocks for '{requested_username}' cannot be accessed."
|
||||
.format(requested_username=requested_username)
|
||||
)
|
||||
|
||||
return requested_user
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Return cleanded data, including additional requested fields.
|
||||
"""
|
||||
cleaned_data = super(BlockListGetForm, self).clean()
|
||||
|
||||
# add additional requested_fields that are specified as separate parameters, if they were requested
|
||||
additional_requested_fields = [
|
||||
'student_view_data',
|
||||
'block_counts',
|
||||
'nav_depth',
|
||||
]
|
||||
for additional_field in additional_requested_fields:
|
||||
field_value = cleaned_data.get(additional_field)
|
||||
if field_value or field_value == 0: # allow 0 as a requested value
|
||||
cleaned_data['requested_fields'].add(additional_field)
|
||||
|
||||
usage_key = cleaned_data.get('usage_key')
|
||||
if not usage_key:
|
||||
return
|
||||
|
||||
cleaned_data['user'] = self.clean_requested_user(cleaned_data, usage_key.course_key)
|
||||
return cleaned_data
|
||||
24
lms/djangoapps/course_api/blocks/permissions.py
Normal file
24
lms/djangoapps/course_api/blocks/permissions.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Encapsulates permissions checks for Course Blocks API
|
||||
"""
|
||||
|
||||
from courseware.access import has_access
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
def can_access_other_users_blocks(requesting_user, course_key):
|
||||
"""
|
||||
Returns whether the requesting_user can access the blocks for
|
||||
other users in the given course.
|
||||
"""
|
||||
return has_access(requesting_user, 'staff', course_key)
|
||||
|
||||
|
||||
def can_access_users_blocks(requested_user, course_key):
|
||||
"""
|
||||
Returns whether blocks for the requested_user is accessible.
|
||||
"""
|
||||
return (
|
||||
(requested_user.id and CourseEnrollment.is_enrolled(requested_user, course_key)) or
|
||||
has_access(requested_user, 'staff', course_key)
|
||||
)
|
||||
83
lms/djangoapps/course_api/blocks/serializers.py
Normal file
83
lms/djangoapps/course_api/blocks/serializers.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Serializers for Course Blocks related return objects.
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from .transformers import SUPPORTED_FIELDS
|
||||
|
||||
|
||||
class BlockSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||
"""
|
||||
Serializer for single course block
|
||||
"""
|
||||
def _get_field(self, block_key, transformer, field_name, default):
|
||||
"""
|
||||
Get the field value requested. The field may be an XBlock field, a
|
||||
transformer block field, or an entire tranformer block data dict.
|
||||
"""
|
||||
if transformer is None:
|
||||
value = self.context['block_structure'].get_xblock_field(block_key, field_name)
|
||||
elif field_name is None:
|
||||
value = self.context['block_structure'].get_transformer_block_data(block_key, transformer)
|
||||
else:
|
||||
value = self.context['block_structure'].get_transformer_block_field(block_key, transformer, field_name)
|
||||
|
||||
return value if (value is not None) else default
|
||||
|
||||
def to_representation(self, block_key):
|
||||
"""
|
||||
Return a serializable representation of the requested block
|
||||
"""
|
||||
# create response data dict for basic fields
|
||||
data = {
|
||||
'id': unicode(block_key),
|
||||
'lms_web_url': reverse(
|
||||
'jump_to',
|
||||
kwargs={'course_id': unicode(block_key.course_key), 'location': unicode(block_key)},
|
||||
request=self.context['request'],
|
||||
),
|
||||
'student_view_url': reverse(
|
||||
'courseware.views.render_xblock',
|
||||
kwargs={'usage_key_string': unicode(block_key)},
|
||||
request=self.context['request'],
|
||||
),
|
||||
}
|
||||
|
||||
# add additional requested fields that are supported by the various transformers
|
||||
for supported_field in SUPPORTED_FIELDS:
|
||||
if supported_field.requested_field_name in self.context['requested_fields']:
|
||||
field_value = self._get_field(
|
||||
block_key,
|
||||
supported_field.transformer,
|
||||
supported_field.block_field_name,
|
||||
supported_field.default_value,
|
||||
)
|
||||
if field_value is not None:
|
||||
# only return fields that have data
|
||||
data[supported_field.serializer_field_name] = field_value
|
||||
|
||||
if 'children' in self.context['requested_fields']:
|
||||
children = self.context['block_structure'].get_children(block_key)
|
||||
if children:
|
||||
data['children'] = [unicode(child) for child in children]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class BlockDictSerializer(serializers.Serializer): # pylint: disable=abstract-method
|
||||
"""
|
||||
Serializer that formats a BlockStructure object to a dictionary, rather
|
||||
than a list, of blocks
|
||||
"""
|
||||
root = serializers.CharField(source='root_block_usage_key')
|
||||
blocks = serializers.SerializerMethodField()
|
||||
|
||||
def get_blocks(self, structure):
|
||||
"""
|
||||
Serialize to a dictionary of blocks keyed by the block's usage_key.
|
||||
"""
|
||||
return {
|
||||
unicode(block_key): BlockSerializer(block_key, context=self.context).data
|
||||
for block_key in structure
|
||||
}
|
||||
0
lms/djangoapps/course_api/blocks/tests/__init__.py
Normal file
0
lms/djangoapps/course_api/blocks/tests/__init__.py
Normal file
32
lms/djangoapps/course_api/blocks/tests/test_api.py
Normal file
32
lms/djangoapps/course_api/blocks/tests/test_api.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Tests for Blocks api.py
|
||||
"""
|
||||
|
||||
from django.test.client import RequestFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import SampleCourseFactory
|
||||
|
||||
from ..api import get_blocks
|
||||
|
||||
|
||||
class TestGetBlocks(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the get_blocks function
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestGetBlocks, self).setUp()
|
||||
self.course = SampleCourseFactory.create()
|
||||
self.user = UserFactory.create()
|
||||
self.request = RequestFactory().get("/dummy")
|
||||
self.request.user = self.user
|
||||
|
||||
def test_basic(self):
|
||||
blocks = get_blocks(self.request, self.course.location, self.user)
|
||||
self.assertEquals(blocks['root'], unicode(self.course.location))
|
||||
# add 1 for the orphaned course about block
|
||||
self.assertEquals(len(blocks['blocks']) + 1, len(self.store.get_items(self.course.id)))
|
||||
|
||||
def test_no_user(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
get_blocks(self.request, self.course.location)
|
||||
212
lms/djangoapps/course_api/blocks/tests/test_forms.py
Normal file
212
lms/djangoapps/course_api/blocks/tests/test_forms.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Tests for Course Blocks forms
|
||||
"""
|
||||
import ddt
|
||||
from django.http import Http404, QueryDict
|
||||
from urllib import urlencode
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from openedx.core.djangoapps.util.test_forms import FormTestMixin
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from ..forms import BlockListGetForm
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestBlockListGetForm(FormTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for BlockListGetForm
|
||||
"""
|
||||
FORM_CLASS = BlockListGetForm
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestBlockListGetForm, cls).setUpClass()
|
||||
|
||||
cls.course = CourseFactory.create()
|
||||
|
||||
def setUp(self):
|
||||
super(TestBlockListGetForm, self).setUp()
|
||||
|
||||
self.student = UserFactory.create()
|
||||
self.student2 = UserFactory.create()
|
||||
self.staff = UserFactory.create(is_staff=True)
|
||||
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
CourseEnrollmentFactory.create(user=self.student2, course_id=self.course.id)
|
||||
|
||||
usage_key = self.course.location
|
||||
self.initial = {'requesting_user': self.student}
|
||||
self.form_data = QueryDict(
|
||||
urlencode({
|
||||
'username': self.student.username,
|
||||
'usage_key': unicode(usage_key),
|
||||
}),
|
||||
mutable=True,
|
||||
)
|
||||
self.cleaned_data = {
|
||||
'block_counts': set(),
|
||||
'depth': 0,
|
||||
'nav_depth': None,
|
||||
'return_type': 'dict',
|
||||
'requested_fields': {'display_name', 'type'},
|
||||
'student_view_data': set(),
|
||||
'usage_key': usage_key,
|
||||
'username': self.student.username,
|
||||
'user': self.student,
|
||||
}
|
||||
|
||||
def assert_raises_permission_denied(self):
|
||||
"""
|
||||
Fail unless permission is denied to the form
|
||||
"""
|
||||
with self.assertRaises(PermissionDenied):
|
||||
self.get_form(expected_valid=False)
|
||||
|
||||
def assert_raises_not_found(self):
|
||||
"""
|
||||
Fail unless a 404 occurs
|
||||
"""
|
||||
with self.assertRaises(Http404):
|
||||
self.get_form(expected_valid=False)
|
||||
|
||||
def assert_equals_cleaned_data(self):
|
||||
"""
|
||||
Check that the form returns the expected data
|
||||
"""
|
||||
form = self.get_form(expected_valid=True)
|
||||
self.assertDictEqual(form.cleaned_data, self.cleaned_data)
|
||||
|
||||
def test_basic(self):
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
#-- usage key
|
||||
|
||||
def test_no_usage_key_param(self):
|
||||
self.form_data.pop('usage_key')
|
||||
self.assert_error('usage_key', "This field is required.")
|
||||
|
||||
def test_invalid_usage_key(self):
|
||||
self.form_data['usage_key'] = 'invalid_usage_key'
|
||||
self.assert_error('usage_key', "'invalid_usage_key' is not a valid usage key.")
|
||||
|
||||
def test_non_existent_usage_key(self):
|
||||
self.form_data['usage_key'] = self.store.make_course_usage_key(CourseLocator('non', 'existent', 'course'))
|
||||
self.assert_raises_permission_denied()
|
||||
|
||||
#-- user
|
||||
|
||||
def test_no_user_param(self):
|
||||
self.form_data.pop('username')
|
||||
self.assert_raises_permission_denied()
|
||||
|
||||
def test_nonexistent_user_by_student(self):
|
||||
self.form_data['username'] = 'non_existent_user'
|
||||
self.assert_raises_permission_denied()
|
||||
|
||||
def test_nonexistent_user_by_staff(self):
|
||||
self.initial = {'requesting_user': self.staff}
|
||||
self.form_data['username'] = 'non_existent_user'
|
||||
self.assert_raises_not_found()
|
||||
|
||||
def test_other_user_by_student(self):
|
||||
self.form_data['username'] = self.student2.username
|
||||
self.assert_raises_permission_denied()
|
||||
|
||||
def test_other_user_by_staff(self):
|
||||
self.initial = {'requesting_user': self.staff}
|
||||
self.get_form(expected_valid=True)
|
||||
|
||||
def test_unenrolled_student(self):
|
||||
CourseEnrollment.unenroll(self.student, self.course.id)
|
||||
self.assert_raises_permission_denied()
|
||||
|
||||
def test_unenrolled_staff(self):
|
||||
CourseEnrollment.unenroll(self.staff, self.course.id)
|
||||
self.initial = {'requesting_user': self.staff}
|
||||
self.form_data['username'] = self.staff.username
|
||||
self.get_form(expected_valid=True)
|
||||
|
||||
def test_unenrolled_student_by_staff(self):
|
||||
CourseEnrollment.unenroll(self.student, self.course.id)
|
||||
self.initial = {'requesting_user': self.staff}
|
||||
self.assert_raises_permission_denied()
|
||||
|
||||
#-- depth
|
||||
|
||||
def test_depth_integer(self):
|
||||
self.form_data['depth'] = 3
|
||||
self.cleaned_data['depth'] = 3
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
def test_depth_all(self):
|
||||
self.form_data['depth'] = 'all'
|
||||
self.cleaned_data['depth'] = None
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
def test_depth_invalid(self):
|
||||
self.form_data['depth'] = 'not_an_integer'
|
||||
self.assert_error('depth', "'not_an_integer' is not a valid depth value.")
|
||||
|
||||
#-- nav depth
|
||||
|
||||
def test_nav_depth(self):
|
||||
self.form_data['nav_depth'] = 3
|
||||
self.cleaned_data['nav_depth'] = 3
|
||||
self.cleaned_data['requested_fields'] |= {'nav_depth'}
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
def test_nav_depth_invalid(self):
|
||||
self.form_data['nav_depth'] = 'not_an_integer'
|
||||
self.assert_error('nav_depth', "Enter a whole number.")
|
||||
|
||||
def test_nav_depth_negative(self):
|
||||
self.form_data['nav_depth'] = -1
|
||||
self.assert_error('nav_depth', "Ensure this value is greater than or equal to 0.")
|
||||
|
||||
#-- return_type
|
||||
|
||||
def test_return_type(self):
|
||||
self.form_data['return_type'] = 'list'
|
||||
self.cleaned_data['return_type'] = 'list'
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
def test_return_type_invalid(self):
|
||||
self.form_data['return_type'] = 'invalid_return_type'
|
||||
self.assert_error(
|
||||
'return_type',
|
||||
"Select a valid choice. invalid_return_type is not one of the available choices."
|
||||
)
|
||||
|
||||
#-- requested fields
|
||||
|
||||
def test_requested_fields(self):
|
||||
self.form_data.setlist('requested_fields', ['graded', 'nav_depth', 'some_other_field'])
|
||||
self.cleaned_data['requested_fields'] |= {'graded', 'nav_depth', 'some_other_field'}
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
@ddt.data('block_counts', 'student_view_data')
|
||||
def test_higher_order_field(self, field_name):
|
||||
field_value = {'block_type1', 'block_type2'}
|
||||
self.form_data.setlist(field_name, field_value)
|
||||
self.cleaned_data[field_name] = field_value
|
||||
self.cleaned_data['requested_fields'].add(field_name)
|
||||
self.assert_equals_cleaned_data()
|
||||
|
||||
def test_combined_fields(self):
|
||||
# add requested fields
|
||||
self.form_data.setlist('requested_fields', ['field1', 'field2'])
|
||||
|
||||
# add higher order fields
|
||||
block_types_list = {'block_type1', 'block_type2'}
|
||||
for field_name in ['block_counts', 'student_view_data']:
|
||||
self.form_data.setlist(field_name, block_types_list)
|
||||
self.cleaned_data[field_name] = block_types_list
|
||||
|
||||
# verify the requested_fields in cleaned_data includes all fields
|
||||
self.cleaned_data['requested_fields'] |= {'field1', 'field2', 'student_view_data', 'block_counts'}
|
||||
self.assert_equals_cleaned_data()
|
||||
149
lms/djangoapps/course_api/blocks/tests/test_serializers.py
Normal file
149
lms/djangoapps/course_api/blocks/tests/test_serializers.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Tests for Course Blocks serializers
|
||||
"""
|
||||
from mock import MagicMock
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ToyCourseFactory
|
||||
from lms.djangoapps.course_blocks.api import get_course_blocks, COURSE_BLOCK_ACCESS_TRANSFORMERS
|
||||
|
||||
from ..transformers.blocks_api import BlocksAPITransformer
|
||||
from ..serializers import BlockSerializer, BlockDictSerializer
|
||||
from .test_utils import deserialize_usage_key
|
||||
|
||||
|
||||
class TestBlockSerializerBase(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Base class for testing BlockSerializer and BlockDictSerializer
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestBlockSerializerBase, cls).setUpClass()
|
||||
|
||||
cls.course = ToyCourseFactory.create()
|
||||
|
||||
def setUp(self):
|
||||
super(TestBlockSerializerBase, self).setUp()
|
||||
|
||||
self.user = UserFactory.create()
|
||||
blocks_api_transformer = BlocksAPITransformer(
|
||||
block_types_to_count=['video'],
|
||||
requested_student_view_data=['video'],
|
||||
)
|
||||
self.block_structure = get_course_blocks(
|
||||
self.user,
|
||||
root_block_usage_key=self.course.location,
|
||||
transformers=COURSE_BLOCK_ACCESS_TRANSFORMERS + [blocks_api_transformer],
|
||||
)
|
||||
self.serializer_context = {
|
||||
'request': MagicMock(),
|
||||
'block_structure': self.block_structure,
|
||||
'requested_fields': ['type'],
|
||||
}
|
||||
|
||||
def assert_basic_block(self, block_key_string, serialized_block):
|
||||
"""
|
||||
Verifies the given serialized_block when basic fields are requested.
|
||||
"""
|
||||
block_key = deserialize_usage_key(block_key_string, self.course.id)
|
||||
self.assertEquals(
|
||||
self.block_structure.get_xblock_field(block_key, 'category'),
|
||||
serialized_block['type'],
|
||||
)
|
||||
self.assertEquals(
|
||||
set(serialized_block.iterkeys()),
|
||||
{'id', 'type', 'lms_web_url', 'student_view_url'},
|
||||
)
|
||||
|
||||
def add_additional_requested_fields(self):
|
||||
"""
|
||||
Adds additional fields to the requested_fields context for the serializer.
|
||||
"""
|
||||
self.serializer_context['requested_fields'].extend([
|
||||
'children',
|
||||
'display_name',
|
||||
'graded',
|
||||
'format',
|
||||
'block_counts',
|
||||
'student_view_data',
|
||||
'student_view_multi_device',
|
||||
])
|
||||
|
||||
def assert_extended_block(self, serialized_block):
|
||||
"""
|
||||
Verifies the given serialized_block when additional fields are requested.
|
||||
"""
|
||||
self.assertLessEqual(
|
||||
{
|
||||
'id', 'type', 'lms_web_url', 'student_view_url',
|
||||
'display_name', 'graded',
|
||||
'block_counts', 'student_view_multi_device',
|
||||
},
|
||||
set(serialized_block.iterkeys()),
|
||||
)
|
||||
|
||||
# video blocks should have student_view_data
|
||||
if serialized_block['type'] == 'video':
|
||||
self.assertIn('student_view_data', serialized_block)
|
||||
|
||||
# html blocks should have student_view_multi_device set to True
|
||||
if serialized_block['type'] == 'html':
|
||||
self.assertIn('student_view_multi_device', serialized_block)
|
||||
self.assertTrue(serialized_block['student_view_multi_device'])
|
||||
|
||||
|
||||
class TestBlockSerializer(TestBlockSerializerBase):
|
||||
"""
|
||||
Tests the BlockSerializer class, which returns a list of blocks.
|
||||
"""
|
||||
|
||||
def create_serializer(self):
|
||||
"""
|
||||
creates a BlockSerializer
|
||||
"""
|
||||
return BlockSerializer(
|
||||
self.block_structure, many=True, context=self.serializer_context,
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
serializer = self.create_serializer()
|
||||
for serialized_block in serializer.data:
|
||||
self.assert_basic_block(serialized_block['id'], serialized_block)
|
||||
|
||||
def test_additional_requested_fields(self):
|
||||
self.add_additional_requested_fields()
|
||||
serializer = self.create_serializer()
|
||||
for serialized_block in serializer.data:
|
||||
self.assert_extended_block(serialized_block)
|
||||
|
||||
|
||||
class TestBlockDictSerializer(TestBlockSerializerBase):
|
||||
"""
|
||||
Tests the BlockDictSerializer class, which returns a dict of blocks key-ed by its block_key.
|
||||
"""
|
||||
|
||||
def create_serializer(self):
|
||||
"""
|
||||
creates a BlockDictSerializer
|
||||
"""
|
||||
return BlockDictSerializer(
|
||||
self.block_structure, many=False, context=self.serializer_context,
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
serializer = self.create_serializer()
|
||||
|
||||
# verify root
|
||||
self.assertEquals(serializer.data['root'], unicode(self.block_structure.root_block_usage_key))
|
||||
|
||||
# verify blocks
|
||||
for block_key_string, serialized_block in serializer.data['blocks'].iteritems():
|
||||
self.assertEquals(serialized_block['id'], block_key_string)
|
||||
self.assert_basic_block(block_key_string, serialized_block)
|
||||
|
||||
def test_additional_requested_fields(self):
|
||||
self.add_additional_requested_fields()
|
||||
serializer = self.create_serializer()
|
||||
for serialized_block in serializer.data['blocks'].itervalues():
|
||||
self.assert_extended_block(serialized_block)
|
||||
12
lms/djangoapps/course_api/blocks/tests/test_utils.py
Normal file
12
lms/djangoapps/course_api/blocks/tests/test_utils.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Helper functions for unit tests
|
||||
"""
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
|
||||
def deserialize_usage_key(usage_key_string, course_key):
|
||||
"""
|
||||
Returns the deserialized UsageKey object of the given usage_key_string for the given course.
|
||||
"""
|
||||
return UsageKey.from_string(usage_key_string).replace(course_key=course_key)
|
||||
258
lms/djangoapps/course_api/blocks/tests/test_views.py
Normal file
258
lms/djangoapps/course_api/blocks/tests/test_views.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Tests for Blocks Views
|
||||
"""
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from string import join
|
||||
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ToyCourseFactory
|
||||
|
||||
from .test_utils import deserialize_usage_key
|
||||
|
||||
|
||||
class TestBlocksViewMixin(object):
|
||||
"""
|
||||
Mixin class for test helpers for BlocksView related classes
|
||||
"""
|
||||
@classmethod
|
||||
def setup_course(cls):
|
||||
"""
|
||||
Create a sample course
|
||||
"""
|
||||
cls.course_key = ToyCourseFactory.create().id
|
||||
|
||||
cls.non_orphaned_block_usage_keys = set(
|
||||
unicode(item.location)
|
||||
for item in cls.store.get_items(cls.course_key)
|
||||
# remove all orphaned items in the course, except for the root 'course' block
|
||||
if cls.store.get_parent_location(item.location) or item.category == 'course'
|
||||
)
|
||||
|
||||
def setup_user(self):
|
||||
"""
|
||||
Create a user, enrolled in the sample course
|
||||
"""
|
||||
self.user = UserFactory.create() # pylint: disable=attribute-defined-outside-init
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.course_key)
|
||||
|
||||
def verify_response(self, expected_status_code=200, params=None, url=None):
|
||||
"""
|
||||
Ensure that the sending a GET request to the specified URL (or self.url)
|
||||
returns the expected status code (200 by default).
|
||||
|
||||
Arguments:
|
||||
expected_status_code: (default 200)
|
||||
params:
|
||||
query parameters to include in the request (includes
|
||||
username=[self.user.username]&depth=all by default)
|
||||
url: (default [self.url])
|
||||
|
||||
Returns:
|
||||
response: The HttpResponse returned by the request
|
||||
"""
|
||||
query_params = {'username': self.user.username, 'depth': 'all'}
|
||||
if params:
|
||||
query_params.update(params)
|
||||
response = self.client.get(url or self.url, query_params)
|
||||
self.assertEquals(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
def verify_response_block_list(self, response):
|
||||
"""
|
||||
Verify that the response contains only the expected block ids.
|
||||
"""
|
||||
self.assertSetEqual(
|
||||
{block['id'] for block in response.data},
|
||||
self.non_orphaned_block_usage_keys,
|
||||
)
|
||||
|
||||
def verify_response_block_dict(self, response):
|
||||
"""
|
||||
Verify that the response contains the expected blocks
|
||||
"""
|
||||
self.assertSetEqual(
|
||||
set(response.data['blocks'].iterkeys()),
|
||||
self.non_orphaned_block_usage_keys,
|
||||
)
|
||||
|
||||
requested_fields = ['graded', 'format', 'student_view_multi_device', 'children', 'not_a_field']
|
||||
|
||||
def verify_response_with_requested_fields(self, response):
|
||||
"""
|
||||
Verify the response has the expected structure
|
||||
"""
|
||||
self.verify_response_block_dict(response)
|
||||
for block_key_string, block_data in response.data['blocks'].iteritems():
|
||||
block_key = deserialize_usage_key(block_key_string, self.course_key)
|
||||
xblock = self.store.get_item(block_key)
|
||||
|
||||
self.assert_in_iff('children', block_data, xblock.has_children)
|
||||
self.assert_in_iff('graded', block_data, xblock.graded is not None)
|
||||
self.assert_in_iff('format', block_data, xblock.format is not None)
|
||||
self.assert_true_iff(block_data['student_view_multi_device'], block_data['type'] == 'html')
|
||||
self.assertNotIn('not_a_field', block_data)
|
||||
|
||||
if xblock.has_children:
|
||||
self.assertSetEqual(
|
||||
set(unicode(child.location) for child in xblock.get_children()),
|
||||
set(block_data['children']),
|
||||
)
|
||||
|
||||
def assert_in_iff(self, member, container, predicate):
|
||||
"""
|
||||
Assert that member is in container if and only if predicate is true.
|
||||
|
||||
Arguments:
|
||||
member - any object
|
||||
container - any container
|
||||
predicate - an expression, tested for truthiness
|
||||
"""
|
||||
if predicate:
|
||||
self.assertIn(member, container)
|
||||
else:
|
||||
self.assertNotIn(member, container)
|
||||
|
||||
def assert_true_iff(self, expression, predicate):
|
||||
"""
|
||||
Assert that the expression is true if and only if the predicate is true
|
||||
|
||||
Arguments:
|
||||
expression
|
||||
predicate
|
||||
"""
|
||||
|
||||
if predicate:
|
||||
self.assertTrue(expression)
|
||||
else:
|
||||
self.assertFalse(expression)
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
class TestBlocksView(TestBlocksViewMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test class for BlocksView
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestBlocksView, cls).setUpClass()
|
||||
cls.setup_course()
|
||||
cls.course_usage_key = cls.store.make_course_usage_key(cls.course_key)
|
||||
cls.url = reverse(
|
||||
'blocks_in_block_tree',
|
||||
kwargs={'usage_key_string': unicode(cls.course_usage_key)}
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super(TestBlocksView, self).setUp()
|
||||
self.setup_user()
|
||||
|
||||
def test_not_authenticated(self):
|
||||
self.client.logout()
|
||||
self.verify_response(401)
|
||||
|
||||
def test_not_enrolled(self):
|
||||
CourseEnrollment.unenroll(self.user, self.course_key)
|
||||
self.verify_response(403)
|
||||
|
||||
def test_non_existent_course(self):
|
||||
usage_key = self.store.make_course_usage_key(CourseLocator('non', 'existent', 'course'))
|
||||
url = reverse(
|
||||
'blocks_in_block_tree',
|
||||
kwargs={'usage_key_string': unicode(usage_key)}
|
||||
)
|
||||
self.verify_response(403, url=url)
|
||||
|
||||
def test_basic(self):
|
||||
response = self.verify_response()
|
||||
self.assertEquals(response.data['root'], unicode(self.course_usage_key))
|
||||
self.verify_response_block_dict(response)
|
||||
for block_key_string, block_data in response.data['blocks'].iteritems():
|
||||
block_key = deserialize_usage_key(block_key_string, self.course_key)
|
||||
self.assertEquals(block_data['id'], block_key_string)
|
||||
self.assertEquals(block_data['type'], block_key.block_type)
|
||||
self.assertEquals(block_data['display_name'], self.store.get_item(block_key).display_name or '')
|
||||
|
||||
def test_return_type_param(self):
|
||||
response = self.verify_response(params={'return_type': 'list'})
|
||||
self.verify_response_block_list(response)
|
||||
|
||||
def test_block_counts_param(self):
|
||||
response = self.verify_response(params={'block_counts': ['course', 'chapter']})
|
||||
self.verify_response_block_dict(response)
|
||||
for block_data in response.data['blocks'].itervalues():
|
||||
self.assertEquals(
|
||||
block_data['block_counts']['course'],
|
||||
1 if block_data['type'] == 'course' else 0,
|
||||
)
|
||||
self.assertEquals(
|
||||
block_data['block_counts']['chapter'],
|
||||
(
|
||||
1 if block_data['type'] == 'chapter' else
|
||||
5 if block_data['type'] == 'course' else
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
def test_student_view_data_param(self):
|
||||
response = self.verify_response(params={'student_view_data': ['video', 'chapter']})
|
||||
self.verify_response_block_dict(response)
|
||||
for block_data in response.data['blocks'].itervalues():
|
||||
self.assert_in_iff('student_view_data', block_data, block_data['type'] == 'video')
|
||||
|
||||
def test_navigation_param(self):
|
||||
response = self.verify_response(params={'nav_depth': 10})
|
||||
self.verify_response_block_dict(response)
|
||||
for block_data in response.data['blocks'].itervalues():
|
||||
self.assertIn('descendants', block_data)
|
||||
|
||||
def test_requested_fields_param(self):
|
||||
response = self.verify_response(
|
||||
params={'requested_fields': self.requested_fields}
|
||||
)
|
||||
self.verify_response_with_requested_fields(response)
|
||||
|
||||
|
||||
class TestBlocksInCourseView(TestBlocksViewMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test class for BlocksInCourseView
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestBlocksInCourseView, cls).setUpClass()
|
||||
cls.setup_course()
|
||||
cls.url = reverse('blocks_in_course')
|
||||
|
||||
def setUp(self):
|
||||
super(TestBlocksInCourseView, self).setUp()
|
||||
self.setup_user()
|
||||
|
||||
def test_basic(self):
|
||||
response = self.verify_response(params={'course_id': unicode(self.course_key)})
|
||||
self.verify_response_block_dict(response)
|
||||
|
||||
def test_no_course_id(self):
|
||||
self.verify_response(400)
|
||||
|
||||
def test_invalid_course_id(self):
|
||||
self.verify_response(400, params={'course_id': 'invalid_course_id'})
|
||||
|
||||
def test_with_list_field_url(self):
|
||||
url = '{base_url}?course_id={course_id}&username={username}&depth=all'.format(
|
||||
course_id=unicode(self.course_key),
|
||||
base_url=self.url.format(),
|
||||
username=self.user.username,
|
||||
)
|
||||
url += '&requested_fields={0}&requested_fields={1}&requested_fields={2}'.format(
|
||||
self.requested_fields[0],
|
||||
self.requested_fields[1],
|
||||
join(self.requested_fields[1:], ','),
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEquals(response.status_code, 200)
|
||||
self.verify_response_with_requested_fields(response)
|
||||
54
lms/djangoapps/course_api/blocks/transformers/__init__.py
Normal file
54
lms/djangoapps/course_api/blocks/transformers/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Course API Block Transformers
|
||||
"""
|
||||
|
||||
from .student_view import StudentViewTransformer
|
||||
from .block_counts import BlockCountsTransformer
|
||||
from .navigation import BlockNavigationTransformer
|
||||
|
||||
|
||||
class SupportedFieldType(object):
|
||||
"""
|
||||
Metadata about fields supported by different transformers
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
block_field_name,
|
||||
transformer=None,
|
||||
requested_field_name=None,
|
||||
serializer_field_name=None,
|
||||
default_value=None
|
||||
):
|
||||
self.transformer = transformer
|
||||
self.block_field_name = block_field_name
|
||||
self.requested_field_name = requested_field_name or block_field_name
|
||||
self.serializer_field_name = serializer_field_name or self.requested_field_name
|
||||
self.default_value = default_value
|
||||
|
||||
|
||||
# A list of metadata for additional requested fields to be used by the
|
||||
# BlockSerializer` class. Each entry provides information on how that field can
|
||||
# be requested (`requested_field_name`), can be found (`transformer` and
|
||||
# `block_field_name`), and should be serialized (`serializer_field_name` and
|
||||
# `default_value`).
|
||||
|
||||
SUPPORTED_FIELDS = [
|
||||
SupportedFieldType('category', requested_field_name='type'),
|
||||
SupportedFieldType('display_name', default_value=''),
|
||||
SupportedFieldType('graded'),
|
||||
SupportedFieldType('format'),
|
||||
# 'student_view_data'
|
||||
SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_DATA, StudentViewTransformer),
|
||||
# 'student_view_multi_device'
|
||||
SupportedFieldType(StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE, StudentViewTransformer),
|
||||
|
||||
# set the block_field_name to None so the entire data for the transformer is serialized
|
||||
SupportedFieldType(None, BlockCountsTransformer, BlockCountsTransformer.BLOCK_COUNTS),
|
||||
|
||||
SupportedFieldType(
|
||||
BlockNavigationTransformer.BLOCK_NAVIGATION,
|
||||
BlockNavigationTransformer,
|
||||
requested_field_name='nav_depth',
|
||||
serializer_field_name='descendants',
|
||||
)
|
||||
]
|
||||
61
lms/djangoapps/course_api/blocks/transformers/blocks_api.py
Normal file
61
lms/djangoapps/course_api/blocks/transformers/blocks_api.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Blocks API Transformer
|
||||
"""
|
||||
from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
|
||||
from .block_counts import BlockCountsTransformer
|
||||
from .block_depth import BlockDepthTransformer
|
||||
from .navigation import BlockNavigationTransformer
|
||||
from .student_view import StudentViewTransformer
|
||||
|
||||
|
||||
class BlocksAPITransformer(BlockStructureTransformer):
|
||||
"""
|
||||
Umbrella transformer that contains all the transformers needed by the
|
||||
Course Blocks API.
|
||||
|
||||
Contained Transformers (in this order):
|
||||
StudentViewTransformer
|
||||
BlockCountsTransformer
|
||||
BlockDepthTransformer
|
||||
BlockNavigationTransformer
|
||||
"""
|
||||
|
||||
VERSION = 1
|
||||
STUDENT_VIEW_DATA = 'student_view_data'
|
||||
STUDENT_VIEW_MULTI_DEVICE = 'student_view_multi_device'
|
||||
|
||||
def __init__(self, block_types_to_count, requested_student_view_data, depth=None, nav_depth=None):
|
||||
self.block_types_to_count = block_types_to_count
|
||||
self.requested_student_view_data = requested_student_view_data
|
||||
self.depth = depth
|
||||
self.nav_depth = nav_depth
|
||||
|
||||
@classmethod
|
||||
def name(cls):
|
||||
return "blocks_api"
|
||||
|
||||
@classmethod
|
||||
def collect(cls, block_structure):
|
||||
"""
|
||||
Collects any information that's necessary to execute this transformer's
|
||||
transform method.
|
||||
"""
|
||||
# collect basic xblock fields
|
||||
block_structure.request_xblock_fields('graded', 'format', 'display_name', 'category')
|
||||
|
||||
# collect data from containing transformers
|
||||
StudentViewTransformer.collect(block_structure)
|
||||
BlockCountsTransformer.collect(block_structure)
|
||||
BlockDepthTransformer.collect(block_structure)
|
||||
BlockNavigationTransformer.collect(block_structure)
|
||||
|
||||
# TODO support olx_data by calling export_to_xml(?)
|
||||
|
||||
def transform(self, usage_info, block_structure):
|
||||
"""
|
||||
Mutates block_structure based on the given usage_info.
|
||||
"""
|
||||
StudentViewTransformer(self.requested_student_view_data).transform(usage_info, block_structure)
|
||||
BlockCountsTransformer(self.block_types_to_count).transform(usage_info, block_structure)
|
||||
BlockDepthTransformer(self.depth).transform(usage_info, block_structure)
|
||||
BlockNavigationTransformer(self.nav_depth).transform(usage_info, block_structure)
|
||||
24
lms/djangoapps/course_api/blocks/urls.py
Normal file
24
lms/djangoapps/course_api/blocks/urls.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Course Block API URLs
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls import patterns, url
|
||||
from .views import BlocksView, BlocksInCourseView
|
||||
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
# This endpoint requires the usage_key for the starting block.
|
||||
url(
|
||||
r'^v1/blocks/{}'.format(settings.USAGE_KEY_PATTERN),
|
||||
BlocksView.as_view(),
|
||||
name="blocks_in_block_tree"
|
||||
),
|
||||
|
||||
# This endpoint is an alternative to the above, but requires course_id as a parameter.
|
||||
url(
|
||||
r'^v1/blocks/',
|
||||
BlocksInCourseView.as_view(),
|
||||
name="blocks_in_course"
|
||||
),
|
||||
)
|
||||
248
lms/djangoapps/course_api/blocks/views.py
Normal file
248
lms/djangoapps/course_api/blocks/views.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
CourseBlocks API views
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import Http404
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes, DeveloperErrorViewMixin
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .api import get_blocks
|
||||
from .forms import BlockListGetForm
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class BlocksView(DeveloperErrorViewMixin, ListAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Returns the blocks within the requested block tree according to the
|
||||
requesting user's access level.
|
||||
|
||||
**Example requests**:
|
||||
|
||||
GET /api/courses/v1/blocks/<root_block_usage_id>/?depth=all
|
||||
GET /api/courses/v1/blocks/<usage_id>/?
|
||||
username=anjali
|
||||
&depth=all
|
||||
&requested_fields=graded,format,student_view_multi_device
|
||||
&block_counts=video
|
||||
&student_view_data=video
|
||||
|
||||
**Parameters**:
|
||||
|
||||
* username: (string) The name of the user on whose behalf we want to
|
||||
see the data.
|
||||
|
||||
Default is the logged in user
|
||||
|
||||
Example: username=anjali
|
||||
|
||||
* student_view_data: (list) Indicates for which block types to return
|
||||
student_view_data.
|
||||
|
||||
Example: student_view_data=video
|
||||
|
||||
* block_counts: (list) Indicates for which block types to return the
|
||||
aggregate count of the blocks.
|
||||
|
||||
Example: block_counts=video,problem
|
||||
|
||||
* requested_fields: (list) Indicates which additional fields to return
|
||||
for each block. For a list of available fields see under `Response
|
||||
Values -> blocks`, below.
|
||||
|
||||
The following fields are always returned: id, type, display_name
|
||||
|
||||
Example: requested_fields=graded,format,student_view_multi_device
|
||||
|
||||
* depth: (integer or all) Indicates how deep to traverse into the blocks
|
||||
hierarchy. A value of all means the entire hierarchy.
|
||||
|
||||
Default is 0
|
||||
|
||||
Example: depth=all
|
||||
|
||||
* nav_depth: (integer)
|
||||
|
||||
WARNING: nav_depth is not supported, and may be removed at any time.
|
||||
|
||||
Indicates how far deep to traverse into the
|
||||
course hierarchy before bundling all the descendants.
|
||||
|
||||
Default is 3 since typical navigational views of the course show a
|
||||
maximum of chapter->sequential->vertical.
|
||||
|
||||
Example: nav_depth=3
|
||||
|
||||
* return_type (string) Indicates in what data type to return the
|
||||
blocks.
|
||||
|
||||
Default is dict. Supported values are: dict, list
|
||||
|
||||
Example: return_type=dict
|
||||
|
||||
**Response Values**
|
||||
|
||||
The following fields are returned with a successful response.
|
||||
|
||||
* root: The ID of the root node of the course blocks.
|
||||
|
||||
* blocks: A dictionary that maps block usage IDs to a collection of
|
||||
information about each block. Each block contains the following
|
||||
fields.
|
||||
|
||||
* id: (string) The usage ID of the block.
|
||||
|
||||
* type: (string) The type of block. Possible values include course,
|
||||
chapter, sequential, vertical, html, problem, video, and
|
||||
discussion. The type can also be the name of a custom type of block
|
||||
used for the course.
|
||||
|
||||
* display_name: (string) The display name of the block.
|
||||
|
||||
* children: (list) If the block has child blocks, a list of IDs of
|
||||
the child blocks. Returned only if "children" is included in the
|
||||
"requested_fields" parameter.
|
||||
|
||||
* block_counts: (dict) For each block type specified in the
|
||||
block_counts parameter to the endpoint, the aggregate number of
|
||||
blocks of that type for this block and all of its descendants.
|
||||
Returned only if the "block_counts" input parameter contains this
|
||||
block's type.
|
||||
|
||||
* graded (boolean) Whether or not the block or any of its descendants
|
||||
is graded. Returned only if "graded" is included in the
|
||||
"requested_fields" parameter.
|
||||
|
||||
* format: (string) The assignment type of the block. Possible values
|
||||
can be "Homework", "Lab", "Midterm Exam", and "Final Exam".
|
||||
Returned only if "format" is included in the "requested_fields"
|
||||
parameter.
|
||||
|
||||
* student_view_data: (dict) The JSON data for this block.
|
||||
Returned only if the "student_view_data" input parameter contains
|
||||
this block's type.
|
||||
|
||||
* student_view_url: (string) The URL to retrieve the HTML rendering
|
||||
of this block's student view. The HTML could include CSS and
|
||||
Javascript code. This field can be used in combination with the
|
||||
student_view_multi_device field to decide whether to display this
|
||||
content to the user.
|
||||
|
||||
This URL can be used as a fallback if the student_view_data for
|
||||
this block type is not supported by the client or the block.
|
||||
|
||||
* student_view_multi_device: (boolean) Whether or not the block's
|
||||
rendering obtained via block_url has support for multiple devices.
|
||||
Returned only if "student_view_multi_device" is included in the
|
||||
"requested_fields" parameter.
|
||||
|
||||
* lms_web_url: (string) The URL to the navigational container of the
|
||||
xBlock on the web LMS. This URL can be used as a further fallback
|
||||
if the student_view_url and the student_view_data fields are not
|
||||
supported.
|
||||
|
||||
"""
|
||||
|
||||
def list(self, request, usage_key_string): # pylint: disable=arguments-differ
|
||||
"""
|
||||
REST API endpoint for listing all the blocks information in the course,
|
||||
while regarding user access and roles.
|
||||
|
||||
Arguments:
|
||||
request - Django request object
|
||||
usage_key_string - The usage key for a block.
|
||||
"""
|
||||
|
||||
# validate request parameters
|
||||
requested_params = request.QUERY_PARAMS.copy()
|
||||
requested_params.update({'usage_key': usage_key_string})
|
||||
params = BlockListGetForm(requested_params, initial={'requesting_user': request.user})
|
||||
if not params.is_valid():
|
||||
raise ValidationError(params.errors)
|
||||
|
||||
try:
|
||||
return Response(
|
||||
get_blocks(
|
||||
request,
|
||||
params.cleaned_data['usage_key'],
|
||||
params.cleaned_data['user'],
|
||||
params.cleaned_data['depth'],
|
||||
params.cleaned_data.get('nav_depth'),
|
||||
params.cleaned_data['requested_fields'],
|
||||
params.cleaned_data.get('block_counts', []),
|
||||
params.cleaned_data.get('student_view_data', []),
|
||||
params.cleaned_data['return_type']
|
||||
)
|
||||
)
|
||||
except ItemNotFoundError as exception:
|
||||
raise Http404("Block not found: {}".format(exception.message))
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class BlocksInCourseView(BlocksView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Returns the blocks in the course according to the requesting user's
|
||||
access level.
|
||||
|
||||
**Example requests**:
|
||||
|
||||
GET /api/courses/v1/blocks/?course_id=<course_id>
|
||||
GET /api/courses/v1/blocks/?course_id=<course_id>
|
||||
&username=anjali
|
||||
&depth=all
|
||||
&requested_fields=graded,format,student_view_multi_device
|
||||
&block_counts=video
|
||||
&student_view_data=video
|
||||
|
||||
**Parameters**:
|
||||
|
||||
This view redirects to /api/courses/v1/blocks/<root_usage_key>/ for the
|
||||
root usage key of the course specified by course_id. The view accepts
|
||||
all parameters accepted by :class:`BlocksView`, plus the following
|
||||
required parameter
|
||||
|
||||
* course_id: (string, required) The ID of the course whose block data
|
||||
we want to return
|
||||
|
||||
**Response Values**
|
||||
|
||||
Responses are identical to those returned by :class:`BlocksView` when
|
||||
passed the root_usage_key of the requested course.
|
||||
|
||||
If the course_id is not supplied, a 400: Bad Request is returned, with
|
||||
a message indicating that course_id is required.
|
||||
|
||||
If an invalid course_id is supplied, a 400: Bad Request is returned,
|
||||
with a message indicating that the course_id is not valid.
|
||||
"""
|
||||
|
||||
def list(self, request): # pylint: disable=arguments-differ
|
||||
"""
|
||||
Retrieves the usage_key for the requested course, and then returns the
|
||||
same information that would be returned by BlocksView.list, called with
|
||||
that usage key
|
||||
|
||||
Arguments:
|
||||
request - Django request object
|
||||
"""
|
||||
|
||||
# convert the requested course_key to the course's root block's usage_key
|
||||
course_key_string = request.QUERY_PARAMS.get('course_id', None)
|
||||
if not course_key_string:
|
||||
raise ValidationError('course_id is required.')
|
||||
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
course_usage_key = modulestore().make_course_usage_key(course_key)
|
||||
except InvalidKeyError:
|
||||
raise ValidationError("'{}' is not a valid course key.".format(unicode(course_key_string)))
|
||||
return super(BlocksInCourseView, self).list(request, course_usage_key)
|
||||
14
lms/djangoapps/course_api/urls.py
Normal file
14
lms/djangoapps/course_api/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Course API URLs
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls import patterns, url, include
|
||||
|
||||
from .views import CourseView
|
||||
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(r'^v1/courses/{}'.format(settings.COURSE_KEY_PATTERN), CourseView.as_view(), name="course_detail"),
|
||||
url(r'', include('course_api.blocks.urls'))
|
||||
)
|
||||
53
lms/djangoapps/course_api/views.py
Normal file
53
lms/djangoapps/course_api/views.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Course API Views
|
||||
"""
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class CourseView(APIView):
|
||||
"""
|
||||
Course API view
|
||||
"""
|
||||
|
||||
def get(self, request, course_key_string):
|
||||
"""
|
||||
Request information on a course specified by `course_key_string`.
|
||||
Body consists of a `blocks_url` that can be used to fetch the
|
||||
blocks for the requested course.
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest)
|
||||
course_key_string
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 on success
|
||||
|
||||
|
||||
Example Usage:
|
||||
|
||||
GET /api/courses/v1/[course_key_string]
|
||||
200 OK
|
||||
|
||||
Example response:
|
||||
|
||||
{"blocks_url": "https://server/api/courses/v1/blocks/[usage_key]"}
|
||||
"""
|
||||
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
course_usage_key = modulestore().make_course_usage_key(course_key)
|
||||
|
||||
blocks_url = reverse(
|
||||
'blocks_in_block_tree',
|
||||
kwargs={'usage_key_string': unicode(course_usage_key)},
|
||||
request=request,
|
||||
)
|
||||
|
||||
return Response({'blocks_url': blocks_url})
|
||||
@@ -6,26 +6,14 @@ from django.forms import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
ChoiceField,
|
||||
Field,
|
||||
Form,
|
||||
IntegerField,
|
||||
MultipleHiddenInput,
|
||||
NullBooleanField,
|
||||
)
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
|
||||
class TopicIdField(Field):
|
||||
"""
|
||||
Field for a list of topic_ids
|
||||
"""
|
||||
widget = MultipleHiddenInput
|
||||
|
||||
def validate(self, value):
|
||||
if value and "" in value:
|
||||
raise ValidationError("This field cannot be empty.")
|
||||
from openedx.core.djangoapps.util.forms import MultiValueField
|
||||
|
||||
|
||||
class _PaginationForm(Form):
|
||||
@@ -49,7 +37,7 @@ class ThreadListGetForm(_PaginationForm):
|
||||
EXCLUSIVE_PARAMS = ["topic_id", "text_search", "following"]
|
||||
|
||||
course_id = CharField()
|
||||
topic_id = TopicIdField(required=False)
|
||||
topic_id = MultiValueField(required=False)
|
||||
text_search = CharField(required=False)
|
||||
following = NullBooleanField(required=False)
|
||||
view = ChoiceField(
|
||||
|
||||
@@ -10,39 +10,10 @@ import ddt
|
||||
from django.http import QueryDict
|
||||
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
|
||||
from openedx.core.djangoapps.util.test_forms import FormTestMixin
|
||||
from discussion_api.forms import CommentListGetForm, ThreadListGetForm
|
||||
|
||||
|
||||
class FormTestMixin(object):
|
||||
"""A mixin for testing forms"""
|
||||
def get_form(self, expected_valid):
|
||||
"""
|
||||
Return a form bound to self.form_data, asserting its validity (or lack
|
||||
thereof) according to expected_valid
|
||||
"""
|
||||
form = self.FORM_CLASS(self.form_data)
|
||||
self.assertEqual(form.is_valid(), expected_valid)
|
||||
return form
|
||||
|
||||
def assert_error(self, expected_field, expected_message):
|
||||
"""
|
||||
Create a form bound to self.form_data, assert its invalidity, and assert
|
||||
that its error dictionary contains one entry with the expected field and
|
||||
message
|
||||
"""
|
||||
form = self.get_form(expected_valid=False)
|
||||
self.assertEqual(form.errors, {expected_field: [expected_message]})
|
||||
|
||||
def assert_field_value(self, field, expected_value):
|
||||
"""
|
||||
Create a form bound to self.form_data, assert its validity, and assert
|
||||
that the given field in the cleaned data has the expected value
|
||||
"""
|
||||
form = self.get_form(expected_valid=True)
|
||||
self.assertEqual(form.cleaned_data[field], expected_value)
|
||||
|
||||
|
||||
class PaginationTestMixin(object):
|
||||
"""A mixin for testing forms with pagination fields"""
|
||||
def test_missing_page(self):
|
||||
@@ -92,7 +63,7 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase):
|
||||
"course_id": CourseLocator.from_string("Foo/Bar/Baz"),
|
||||
"page": 2,
|
||||
"page_size": 13,
|
||||
"topic_id": [],
|
||||
"topic_id": set(),
|
||||
"text_search": "",
|
||||
"following": None,
|
||||
"view": "",
|
||||
@@ -106,7 +77,7 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase):
|
||||
form = self.get_form(expected_valid=True)
|
||||
self.assertEqual(
|
||||
form.cleaned_data["topic_id"],
|
||||
["example topic_id", "example 2nd topic_id"],
|
||||
{"example topic_id", "example 2nd topic_id"},
|
||||
)
|
||||
|
||||
def test_text_search(self):
|
||||
|
||||
@@ -86,7 +86,7 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Not found."}
|
||||
{"developer_message": "Course not found."}
|
||||
)
|
||||
|
||||
def test_get_success(self):
|
||||
@@ -147,7 +147,7 @@ class CourseTopicsViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Not found."}
|
||||
{"developer_message": "Course not found."}
|
||||
)
|
||||
|
||||
def test_get_success(self):
|
||||
@@ -207,7 +207,7 @@ class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
|
||||
self.assert_response_correct(
|
||||
response,
|
||||
404,
|
||||
{"developer_message": "Not found."}
|
||||
{"developer_message": "Course not found."}
|
||||
)
|
||||
|
||||
def test_basic(self):
|
||||
|
||||
@@ -84,6 +84,9 @@ urlpatterns = (
|
||||
# Course content API
|
||||
url(r'^api/course_structure/', include('course_structure_api.urls', namespace='course_structure_api')),
|
||||
|
||||
# Course API
|
||||
url(r'^api/courses/', include('course_api.urls')),
|
||||
|
||||
# User API endpoints
|
||||
url(r'^api/user/', include('openedx.core.djangoapps.user_api.urls')),
|
||||
|
||||
|
||||
44
openedx/core/djangoapps/util/forms.py
Normal file
44
openedx/core/djangoapps/util/forms.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Custom forms-related types
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import Field, MultipleHiddenInput
|
||||
|
||||
|
||||
class MultiValueField(Field):
|
||||
"""
|
||||
Field class that supports a set of values for a single form field.
|
||||
|
||||
The field input can be specified as:
|
||||
1. a comma-separated-list (foo:bar1,bar2,bar3), or
|
||||
2. a repeated field in a MultiValueDict (foo:bar1, foo:bar2, foo:bar3)
|
||||
3. a combination of the above (foo:bar1,bar2, foo:bar3)
|
||||
|
||||
Note that there is currently no way to pass a value that includes a comma.
|
||||
|
||||
The resulting field value is a python set of the values as strings.
|
||||
"""
|
||||
widget = MultipleHiddenInput
|
||||
|
||||
def to_python(self, list_of_string_values):
|
||||
"""
|
||||
Convert the form input to a list of strings
|
||||
"""
|
||||
values = super(MultiValueField, self).to_python(list_of_string_values) or set()
|
||||
|
||||
if values:
|
||||
# combine all values if there were multiple specified individually
|
||||
values = ','.join(values)
|
||||
|
||||
# parse them into a set
|
||||
values = set(values.split(',')) if values else set()
|
||||
|
||||
return values
|
||||
|
||||
def validate(self, values):
|
||||
"""
|
||||
Ensure no empty values were passed
|
||||
"""
|
||||
if values and "" in values:
|
||||
raise ValidationError("This field cannot be empty.")
|
||||
32
openedx/core/djangoapps/util/test_forms.py
Normal file
32
openedx/core/djangoapps/util/test_forms.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Mixins for testing forms.
|
||||
"""
|
||||
|
||||
|
||||
class FormTestMixin(object):
|
||||
"""A mixin for testing forms"""
|
||||
def get_form(self, expected_valid):
|
||||
"""
|
||||
Return a form bound to self.form_data, asserting its validity (or lack
|
||||
thereof) according to expected_valid
|
||||
"""
|
||||
form = self.FORM_CLASS(self.form_data, initial=getattr(self, 'initial', None))
|
||||
self.assertEqual(form.is_valid(), expected_valid)
|
||||
return form
|
||||
|
||||
def assert_error(self, expected_field, expected_message):
|
||||
"""
|
||||
Create a form bound to self.form_data, assert its invalidity, and assert
|
||||
that its error dictionary contains one entry with the expected field and
|
||||
message
|
||||
"""
|
||||
form = self.get_form(expected_valid=False)
|
||||
self.assertEqual(form.errors, {expected_field: [expected_message]})
|
||||
|
||||
def assert_field_value(self, field, expected_value):
|
||||
"""
|
||||
Create a form bound to self.form_data, assert its validity, and assert
|
||||
that the given field in the cleaned data has the expected value
|
||||
"""
|
||||
form = self.get_form(expected_valid=True)
|
||||
self.assertEqual(form.cleaned_data[field], expected_value)
|
||||
@@ -69,7 +69,7 @@ class DeveloperErrorViewMixin(object):
|
||||
if isinstance(exc, APIException):
|
||||
return self.make_error_response(exc.status_code, exc.detail)
|
||||
elif isinstance(exc, Http404):
|
||||
return self.make_error_response(404, "Not found.")
|
||||
return self.make_error_response(404, exc.message or "Not found.")
|
||||
elif isinstance(exc, ValidationError):
|
||||
return self.make_validation_error_response(exc)
|
||||
else:
|
||||
|
||||
@@ -398,7 +398,7 @@ class BlockStructureBlockData(BlockStructure):
|
||||
else:
|
||||
return block_data.transformer_data.get(transformer.name(), default)
|
||||
|
||||
def remove_transformer_block_data(self, usage_key, transformer):
|
||||
def remove_transformer_block_field(self, usage_key, transformer, key):
|
||||
"""
|
||||
Deletes the given transformer's entire data dict for the
|
||||
block identified by the given usage_key.
|
||||
@@ -410,7 +410,8 @@ class BlockStructureBlockData(BlockStructure):
|
||||
transformer (BlockStructureTransformer) - The transformer
|
||||
whose data entry is to be deleted.
|
||||
"""
|
||||
self._block_data_map[usage_key].transformer_data.pop(transformer.name(), None)
|
||||
transformer_block_data = self.get_transformer_block_data(usage_key, transformer)
|
||||
transformer_block_data.pop(key, None)
|
||||
|
||||
def remove_block(self, usage_key, keep_descendants):
|
||||
"""
|
||||
@@ -488,6 +489,19 @@ class BlockStructureBlockData(BlockStructure):
|
||||
for _ in self.topological_traversal(filter_func=filter_func, **kwargs):
|
||||
pass
|
||||
|
||||
def get_block_keys(self):
|
||||
"""
|
||||
Returns the block keys in the block structure.
|
||||
|
||||
Returns:
|
||||
iterator(UsageKey) - An iterator of the usage
|
||||
keys of all the blocks in the block structure.
|
||||
"""
|
||||
return self._block_relations.iterkeys()
|
||||
|
||||
#--- Internal methods ---#
|
||||
# To be used within the block_cache framework or by tests.
|
||||
|
||||
def _get_transformer_data_version(self, transformer):
|
||||
"""
|
||||
Returns the version number stored for the given transformer.
|
||||
|
||||
3
setup.py
3
setup.py
@@ -50,9 +50,12 @@ setup(
|
||||
],
|
||||
"openedx.block_structure_transformer": [
|
||||
"library_content = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryTransformer",
|
||||
"split_test = lms.djangoapps.course_blocks.transformers.split_test:SplitTestTransformer",
|
||||
"start_date = lms.djangoapps.course_blocks.transformers.start_date:StartDateTransformer",
|
||||
"user_partitions = lms.djangoapps.course_blocks.transformers.user_partitions:UserPartitionTransformer",
|
||||
"visibility = lms.djangoapps.course_blocks.transformers.visibility:VisibilityTransformer",
|
||||
"course_blocks_api = lms.djangoapps.course_api.blocks.transformers.blocks_api:BlocksAPITransformer",
|
||||
"proctored_exam = lms.djangoapps.course_api.blocks.transformers.proctored_exam:ProctoredExamTransformer",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user