Commit 00e92371 by Nimisha Asthagiri Committed by J. Cliff Dyer

Course Blocks API

parent cbf90677
"""
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
"""
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
"""
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)
)
"""
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
}
"""
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)
"""
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()
"""
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)
"""
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)
"""
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)
"""
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',
)
]
"""
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)
"""
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"
),
)
"""
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)
"""
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'))
)
"""
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')),
......
"""
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.")
"""
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.
......
......@@ -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",
],
}
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment