Course Blocks API

This commit is contained in:
Nimisha Asthagiri
2015-10-28 18:50:12 -04:00
committed by J. Cliff Dyer
parent cbf90677b7
commit 00e9237153
27 changed files with 1524 additions and 52 deletions

View File

@@ -0,0 +1 @@
""" Course API """

View File

@@ -0,0 +1,3 @@
"""
Course API Blocks
"""

View 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

View 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

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

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

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

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

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

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

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

View 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',
)
]

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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')),

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

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

View File

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

View File

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

View File

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