diff --git a/lms/djangoapps/course_api/__init__.py b/lms/djangoapps/course_api/__init__.py new file mode 100644 index 0000000000..263b2b7830 --- /dev/null +++ b/lms/djangoapps/course_api/__init__.py @@ -0,0 +1 @@ +""" Course API """ diff --git a/lms/djangoapps/course_api/blocks/__init__.py b/lms/djangoapps/course_api/blocks/__init__.py new file mode 100644 index 0000000000..e00766fce4 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/__init__.py @@ -0,0 +1,3 @@ +""" +Course API Blocks +""" diff --git a/lms/djangoapps/course_api/blocks/api.py b/lms/djangoapps/course_api/blocks/api.py new file mode 100644 index 0000000000..716951039a --- /dev/null +++ b/lms/djangoapps/course_api/blocks/api.py @@ -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 diff --git a/lms/djangoapps/course_api/blocks/forms.py b/lms/djangoapps/course_api/blocks/forms.py new file mode 100644 index 0000000000..ca258b38eb --- /dev/null +++ b/lms/djangoapps/course_api/blocks/forms.py @@ -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 diff --git a/lms/djangoapps/course_api/blocks/permissions.py b/lms/djangoapps/course_api/blocks/permissions.py new file mode 100644 index 0000000000..11fc442e4d --- /dev/null +++ b/lms/djangoapps/course_api/blocks/permissions.py @@ -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) + ) diff --git a/lms/djangoapps/course_api/blocks/serializers.py b/lms/djangoapps/course_api/blocks/serializers.py new file mode 100644 index 0000000000..f0682f3041 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/serializers.py @@ -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 + } diff --git a/lms/djangoapps/course_api/blocks/tests/__init__.py b/lms/djangoapps/course_api/blocks/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/course_api/blocks/tests/test_api.py b/lms/djangoapps/course_api/blocks/tests/test_api.py new file mode 100644 index 0000000000..c6828b8804 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/test_api.py @@ -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) diff --git a/lms/djangoapps/course_api/blocks/tests/test_forms.py b/lms/djangoapps/course_api/blocks/tests/test_forms.py new file mode 100644 index 0000000000..822130535a --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/test_forms.py @@ -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() diff --git a/lms/djangoapps/course_api/blocks/tests/test_serializers.py b/lms/djangoapps/course_api/blocks/tests/test_serializers.py new file mode 100644 index 0000000000..010b112e6c --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/test_serializers.py @@ -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) diff --git a/lms/djangoapps/course_api/blocks/tests/test_utils.py b/lms/djangoapps/course_api/blocks/tests/test_utils.py new file mode 100644 index 0000000000..9f10fa7ab1 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/test_utils.py @@ -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) diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py new file mode 100644 index 0000000000..35deae23ae --- /dev/null +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/course_api/blocks/transformers/__init__.py b/lms/djangoapps/course_api/blocks/transformers/__init__.py new file mode 100644 index 0000000000..31967e2ce2 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/__init__.py @@ -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', + ) +] diff --git a/lms/djangoapps/course_api/blocks/transformers/blocks_api.py b/lms/djangoapps/course_api/blocks/transformers/blocks_api.py new file mode 100644 index 0000000000..b8be4ef29d --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/blocks_api.py @@ -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) diff --git a/lms/djangoapps/course_api/blocks/urls.py b/lms/djangoapps/course_api/blocks/urls.py new file mode 100644 index 0000000000..e40a7d7082 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/urls.py @@ -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" + ), +) diff --git a/lms/djangoapps/course_api/blocks/views.py b/lms/djangoapps/course_api/blocks/views.py new file mode 100644 index 0000000000..ed44d1f496 --- /dev/null +++ b/lms/djangoapps/course_api/blocks/views.py @@ -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//?depth=all + GET /api/courses/v1/blocks//? + 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= + GET /api/courses/v1/blocks/?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// 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) diff --git a/lms/djangoapps/course_api/urls.py b/lms/djangoapps/course_api/urls.py new file mode 100644 index 0000000000..7800ad2e67 --- /dev/null +++ b/lms/djangoapps/course_api/urls.py @@ -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')) +) diff --git a/lms/djangoapps/course_api/views.py b/lms/djangoapps/course_api/views.py new file mode 100644 index 0000000000..64d20bd889 --- /dev/null +++ b/lms/djangoapps/course_api/views.py @@ -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}) diff --git a/lms/djangoapps/discussion_api/forms.py b/lms/djangoapps/discussion_api/forms.py index 62c902bd65..c986663a3e 100644 --- a/lms/djangoapps/discussion_api/forms.py +++ b/lms/djangoapps/discussion_api/forms.py @@ -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( diff --git a/lms/djangoapps/discussion_api/tests/test_forms.py b/lms/djangoapps/discussion_api/tests/test_forms.py index e6ea3931cb..28db7d9a9a 100644 --- a/lms/djangoapps/discussion_api/tests/test_forms.py +++ b/lms/djangoapps/discussion_api/tests/test_forms.py @@ -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): diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index d3c6a4517f..b2c1090440 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -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): diff --git a/lms/urls.py b/lms/urls.py index 07c28cc589..d360848c73 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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')), diff --git a/openedx/core/djangoapps/util/forms.py b/openedx/core/djangoapps/util/forms.py new file mode 100644 index 0000000000..aa7c5a6f4c --- /dev/null +++ b/openedx/core/djangoapps/util/forms.py @@ -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.") diff --git a/openedx/core/djangoapps/util/test_forms.py b/openedx/core/djangoapps/util/test_forms.py new file mode 100644 index 0000000000..818d76c9a4 --- /dev/null +++ b/openedx/core/djangoapps/util/test_forms.py @@ -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) diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index d736b86bc0..fe57f6faa8 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -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: diff --git a/openedx/core/lib/block_cache/block_structure.py b/openedx/core/lib/block_cache/block_structure.py index a8db0497e7..4fedfe38d1 100644 --- a/openedx/core/lib/block_cache/block_structure.py +++ b/openedx/core/lib/block_cache/block_structure.py @@ -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. diff --git a/setup.py b/setup.py index b59b5fd156..83478fe3b4 100644 --- a/setup.py +++ b/setup.py @@ -50,9 +50,12 @@ setup( ], "openedx.block_structure_transformer": [ "library_content = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryTransformer", + "split_test = lms.djangoapps.course_blocks.transformers.split_test:SplitTestTransformer", "start_date = lms.djangoapps.course_blocks.transformers.start_date:StartDateTransformer", "user_partitions = lms.djangoapps.course_blocks.transformers.user_partitions:UserPartitionTransformer", "visibility = lms.djangoapps.course_blocks.transformers.visibility:VisibilityTransformer", + "course_blocks_api = lms.djangoapps.course_api.blocks.transformers.blocks_api:BlocksAPITransformer", + "proctored_exam = lms.djangoapps.course_api.blocks.transformers.proctored_exam:ProctoredExamTransformer", ], } )