Commit 222bdd98 by Cliff Dyer

Merge pull request #10411 from edx/mobile/course-blocks-api

Course Blocks API
parents cbf90677 46523b10
......@@ -574,15 +574,6 @@ class LoncapaProblem(object):
log.warning("Could not find matching input for id: %s", input_id)
return {}
@property
def has_multi_device_support(self):
"""
Returns whether this capa problem has multi-device support.
"""
return all(
responder.multi_device_support for responder in self.responders.values()
)
# ======= Private Methods Below ========
def _process_includes(self):
......
......@@ -230,9 +230,11 @@ class CapaDescriptor(CapaFields, RawDescriptor):
Returns whether the given view has support for the given functionality.
"""
if functionality == "multi_device":
return self.lcp.has_multi_device_support
else:
return False
return all(
responsetypes.registry.get_class_for_tag(tag).multi_device_support
for tag in self.problem_types
)
return False
# Proxy to CapaModule for access to any of its attributes
answer_available = module_attr('answer_available')
......
......@@ -20,6 +20,7 @@ from xmodule.x_module import XModule, DEPRECATION_VSCOMPAT_EVENT
from xmodule.xml_module import XmlDescriptor, name_to_pathname
from xblock.core import XBlock
from xblock.fields import Scope, String, Boolean, List
from xblock.fragment import Fragment
log = logging.getLogger("edx.courseware")
......@@ -28,7 +29,12 @@ log = logging.getLogger("edx.courseware")
_ = lambda text: text
class HtmlFields(object):
class HtmlBlock(object):
"""
This will eventually subclass XBlock and merge HtmlModule and HtmlDescriptor
into one. For now, it's a place to put the pieces that are already sharable
between the two (field information and XBlock handlers).
"""
display_name = String(
display_name=_("Display Name"),
help=_("This name appears in the horizontal navigation at the top of the page."),
......@@ -38,14 +44,20 @@ class HtmlFields(object):
default=_("Text")
)
data = String(help=_("Html contents to display for this module"), default=u"", scope=Scope.content)
source_code = String(help=_("Source code for LaTeX documents. This feature is not well-supported."), scope=Scope.settings)
source_code = String(
help=_("Source code for LaTeX documents. This feature is not well-supported."),
scope=Scope.settings
)
use_latex_compiler = Boolean(
help=_("Enable LaTeX templates?"),
default=False,
scope=Scope.settings
)
editor = String(
help=_("Select Visual to enter content and have the editor automatically create the HTML. Select Raw to edit HTML directly. If you change this setting, you must save the component and then re-open it for editing."),
help=_(
"Select Visual to enter content and have the editor automatically create the HTML. Select Raw to edit "
"HTML directly. If you change this setting, you must save the component and then re-open it for editing."
),
display_name=_("Editor"),
default="visual",
values=[
......@@ -55,8 +67,25 @@ class HtmlFields(object):
scope=Scope.settings
)
@XBlock.supports("multi_device")
def student_view(self, _context):
"""
Return a fragment that contains the html for the student view
"""
return Fragment(self.get_html())
class HtmlModuleMixin(HtmlFields, XModule):
def get_html(self):
"""
When we switch this to an XBlock, we can merge this with student_view,
but for now the XModule mixin requires that this method be defined.
"""
# pylint: disable=no-member
if self.system.anonymous_student_id:
return self.data.replace("%%USER_ID%%", self.system.anonymous_student_id)
return self.data
class HtmlModuleMixin(HtmlBlock, XModule):
"""
Attributes and methods used by HtmlModules internally.
"""
......@@ -74,23 +103,15 @@ class HtmlModuleMixin(HtmlFields, XModule):
js_module_name = "HTMLModule"
css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
def get_html(self):
if self.system.anonymous_student_id:
return self.data.replace("%%USER_ID%%", self.system.anonymous_student_id)
return self.data
@edxnotes
class HtmlModule(HtmlModuleMixin):
"""
Module for putting raw html in a course
"""
@XBlock.supports("multi_device")
def student_view(self, context):
return super(HtmlModule, self).student_view(context)
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: disable=abstract-method
class HtmlDescriptor(HtmlBlock, XmlDescriptor, EditingDescriptor): # pylint: disable=abstract-method
"""
Module for putting raw html in a course
"""
......@@ -107,28 +128,31 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: d
# VS[compat] TODO (cpennington): Delete this method once all fall 2012 course
# are being edited in the cms
@classmethod
def backcompat_paths(cls, path):
def backcompat_paths(cls, filepath):
"""
Get paths for html and xml files.
"""
dog_stats_api.increment(
DEPRECATION_VSCOMPAT_EVENT,
tags=["location:html_descriptor_backcompat_paths"]
)
if path.endswith('.html.xml'):
path = path[:-9] + '.html' # backcompat--look for html instead of xml
if path.endswith('.html.html'):
path = path[:-5] # some people like to include .html in filenames..
if filepath.endswith('.html.xml'):
filepath = filepath[:-9] + '.html' # backcompat--look for html instead of xml
if filepath.endswith('.html.html'):
filepath = filepath[:-5] # some people like to include .html in filenames..
candidates = []
while os.sep in path:
candidates.append(path)
_, _, path = path.partition(os.sep)
while os.sep in filepath:
candidates.append(filepath)
_, _, filepath = filepath.partition(os.sep)
# also look for .html versions instead of .xml
nc = []
new_candidates = []
for candidate in candidates:
if candidate.endswith('.xml'):
nc.append(candidate[:-4] + '.html')
return candidates + nc
new_candidates.append(candidate[:-4] + '.html')
return candidates + new_candidates
@classmethod
def filter_templates(cls, template, course):
......@@ -217,8 +241,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: d
break
try:
with system.resources_fs.open(filepath) as file:
html = file.read().decode('utf-8')
with system.resources_fs.open(filepath) as infile:
html = infile.read().decode('utf-8')
# Log a warning if we can't parse the file, but don't error
if not check_html(html) and len(html) > 0:
msg = "Couldn't parse html in {0}, content = {1}".format(filepath, html)
......
......@@ -776,11 +776,13 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
"""
return edxval_api.get_video_info_for_course_and_profiles(unicode(course_id), video_profile_names)
def student_view_json(self, context):
def student_view_data(self, context=None):
"""
Returns a JSON representation of the student_view of this XModule.
The contract of the JSON content is between the caller and the particular XModule.
"""
context = context or {}
# If the "only_on_web" field is set on this video, do not return the rest of the video's data
# in this json view, since this video is to be accessed only through its web view."
if self.only_on_web:
......@@ -791,7 +793,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
# Check in VAL data first if edx_video_id exists
if self.edx_video_id:
video_profile_names = context.get("profiles", [])
video_profile_names = context.get("profiles", ["mobile_low"])
# get and cache bulk VAL data for course
val_course_data = self.get_cached_val_data_for_course(video_profile_names, self.location.course_key)
......
"""
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',
)
]
"""
Block Counts Transformer
"""
from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
class BlockCountsTransformer(BlockStructureTransformer):
"""
Keep a count of descendant blocks of the requested types
"""
VERSION = 1
BLOCK_COUNTS = 'block_counts'
def __init__(self, block_types_to_count):
self.block_types_to_count = block_types_to_count
@classmethod
def name(cls):
return "blocks_api:block_counts"
@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('category')
def transform(self, usage_info, block_structure): # pylint: disable=unused-argument
"""
Mutates block_structure based on the given usage_info.
"""
if not self.block_types_to_count:
return
for block_key in block_structure.post_order_traversal():
for block_type in self.block_types_to_count:
descendants_type_count = sum([
block_structure.get_transformer_block_field(child_key, self, block_type, 0)
for child_key in block_structure.get_children(block_key)
])
block_structure.set_transformer_block_field(
block_key,
self,
block_type,
(
descendants_type_count +
(1 if (block_structure.get_xblock_field(block_key, 'category') == block_type) else 0)
)
)
"""
Block Depth Transformer
"""
from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
class BlockDepthTransformer(BlockStructureTransformer):
"""
Keep track of the depth of each block within the block structure. In case
of multiple paths to a given node (in a DAG), use the shallowest depth.
"""
VERSION = 1
BLOCK_DEPTH = 'block_depth'
def __init__(self, requested_depth=None):
self.requested_depth = requested_depth
@classmethod
def name(cls):
return "blocks_api:block_depth"
@classmethod
def get_block_depth(cls, block_structure, block_key):
"""
Return the precalculated depth of a block within the block_structure:
Arguments:
block_structure: a BlockStructure instance
block_key: the key of the block whose depth we want to know
Returns:
int
"""
return block_structure.get_transformer_block_field(
block_key,
cls,
cls.BLOCK_DEPTH,
)
def transform(self, usage_info, block_structure): # pylint: disable=unused-argument
"""
Mutates block_structure based on the given usage_info.
"""
for block_key in block_structure.topological_traversal():
parents = block_structure.get_parents(block_key)
if parents:
block_depth = min(
self.get_block_depth(block_structure, parent_key)
for parent_key in parents
) + 1
else:
block_depth = 0
block_structure.set_transformer_block_field(
block_key,
self,
self.BLOCK_DEPTH,
block_depth
)
if self.requested_depth is not None:
block_structure.remove_block_if(
lambda block_key: self.get_block_depth(block_structure, block_key) > self.requested_depth
)
"""
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 (processed in this order):
StudentViewTransformer
BlockCountsTransformer
BlockDepthTransformer
BlockNavigationTransformer
Note: BlockDepthTransformer must be executed before 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)
"""
TODO
"""
from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
from .block_depth import BlockDepthTransformer
class DescendantList(object):
"""
Contain
"""
def __init__(self):
self.items = []
class BlockNavigationTransformer(BlockStructureTransformer):
"""
Creates a table of contents for the course.
Prerequisites: BlockDepthTransformer must be run before this in the
transform phase.
"""
VERSION = 1
BLOCK_NAVIGATION = 'block_nav'
BLOCK_NAVIGATION_FOR_CHILDREN = 'children_block_nav'
def __init__(self, nav_depth):
self.nav_depth = nav_depth
@classmethod
def name(cls):
return "blocks_api:block_navigation"
@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('hide_from_toc')
def transform(self, usage_info, block_structure): # pylint: disable=unused-argument
"""
Mutates block_structure based on the given usage_info.
"""
if self.nav_depth is None:
return
for block_key in block_structure.topological_traversal():
parents = block_structure.get_parents(block_key)
parents_descendants_list = set()
for parent_key in parents:
parent_nav = block_structure.get_transformer_block_field(
parent_key,
self,
self.BLOCK_NAVIGATION_FOR_CHILDREN,
)
if parent_nav is not None:
parents_descendants_list |= parent_nav
children_descendants_list = None
if (
not block_structure.get_xblock_field(block_key, 'hide_from_toc', False) and (
not parents or
any(parent_desc_list is not None for parent_desc_list in parents_descendants_list)
)
):
# add self to parent's descendants
for parent_desc_list in parents_descendants_list:
if parent_desc_list is not None:
parent_desc_list.items.append(unicode(block_key))
if BlockDepthTransformer.get_block_depth(block_structure, block_key) > self.nav_depth:
children_descendants_list = parents_descendants_list
else:
block_nav_list = DescendantList()
children_descendants_list = {block_nav_list}
block_structure.set_transformer_block_field(
block_key,
self,
self.BLOCK_NAVIGATION,
block_nav_list.items
)
block_structure.set_transformer_block_field(
block_key,
self,
self.BLOCK_NAVIGATION_FOR_CHILDREN,
children_descendants_list
)
"""
Proctored Exams Transformer
"""
from django.conf import settings
from edx_proctoring.api import get_attempt_status_summary
from edx_proctoring.models import ProctoredExamStudentAttemptStatus
from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
class ProctoredExamTransformer(BlockStructureTransformer):
"""
Exclude proctored exams unless the user is not a verified student or has
declined taking the exam.
"""
VERSION = 1
BLOCK_HAS_PROCTORED_EXAM = 'has_proctored_exam'
@classmethod
def name(cls):
return "proctored_exam"
@classmethod
def collect(cls, block_structure):
"""
Computes any information for each XBlock that's necessary to execute
this transformer's transform method.
Arguments:
block_structure (BlockStructureCollectedData)
"""
block_structure.request_xblock_fields('is_proctored_enabled')
block_structure.request_xblock_fields('is_practice_exam')
def transform(self, usage_info, block_structure):
"""
Mutates block_structure based on the given usage_info.
"""
if not settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False):
return
def is_proctored_exam_for_user(block_key):
"""
Test whether the block is a proctored exam for the user in
question.
"""
if (
block_key.block_type == 'sequential' and (
block_structure.get_xblock_field(block_key, 'is_proctored_enabled') or
block_structure.get_xblock_field(block_key, 'is_practice_exam')
)
):
# This section is an exam. It should be excluded unless the
# user is not a verified student or has declined taking the exam.
user_exam_summary = get_attempt_status_summary(
usage_info.user.id,
unicode(block_key.course_key),
unicode(block_key),
)
return user_exam_summary and user_exam_summary['status'] != ProctoredExamStudentAttemptStatus.declined
block_structure.remove_block_if(is_proctored_exam_for_user)
"""
Student View Transformer
"""
from openedx.core.lib.block_cache.transformer import BlockStructureTransformer
class StudentViewTransformer(BlockStructureTransformer):
"""
Only show information that is appropriate for a learner
"""
VERSION = 1
STUDENT_VIEW_DATA = 'student_view_data'
STUDENT_VIEW_MULTI_DEVICE = 'student_view_multi_device'
def __init__(self, requested_student_view_data=None):
self.requested_student_view_data = requested_student_view_data or []
@classmethod
def name(cls):
return "blocks_api:student_view"
@classmethod
def collect(cls, block_structure):
"""
Collect student_view_multi_device and student_view_data values for each block
"""
# collect basic xblock fields
block_structure.request_xblock_fields('category')
for block_key in block_structure.topological_traversal():
block = block_structure.get_xblock(block_key)
# We're iterating through descriptors (not bound to a user) that are
# given to us by the modulestore. The reason we look at
# block.__class__ is to avoid the XModuleDescriptor -> XModule
# proxying that would happen if we just examined block directly,
# since it's likely that student_view() is going to be defined on
# the XModule side.
#
# If that proxying happens, this method will throw an
# UndefinedContext exception, because we haven't initialized any of
# the user-specific context.
#
# This isn't a problem for pure XBlocks, because it's all in one
# class, and there's no proxying. So basically, if you encounter a
# problem where your particular XModule explodes here (and don't
# have the time to convert it to an XBlock), please try refactoring
# so that you declare your student_view() method in a common
# ancestor class of both your Descriptor and Module classes. As an
# example, I changed the name of HtmlFields to HtmlBlock and moved
# student_view() from HtmlModuleMixin to HtmlBlock.
student_view = getattr(block.__class__, 'student_view', None)
supports_multi_device = block.has_support(student_view, 'multi_device')
block_structure.set_transformer_block_field(
block_key,
cls,
cls.STUDENT_VIEW_MULTI_DEVICE,
supports_multi_device,
)
if getattr(block, 'student_view_data', None):
student_view_data = block.student_view_data()
block_structure.set_transformer_block_field(
block_key,
cls,
cls.STUDENT_VIEW_DATA,
student_view_data,
)
def transform(self, usage_info, block_structure): # pylint: disable=unused-argument
"""
Mutates block_structure based on the given usage_info.
"""
for block_key in block_structure.post_order_traversal():
if block_structure.get_xblock_field(block_key, 'category') not in self.requested_student_view_data:
block_structure.remove_transformer_block_field(block_key, self, self.STUDENT_VIEW_DATA)
"""
Tests for BlockCountsTransformer.
"""
# pylint: disable=protected-access
from openedx.core.lib.block_cache.block_structure_factory import BlockStructureFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import SampleCourseFactory
from ..block_counts import BlockCountsTransformer
class TestBlockCountsTransformer(ModuleStoreTestCase):
"""
Test behavior of BlockCountsTransformer
"""
def setUp(self):
super(TestBlockCountsTransformer, self).setUp()
self.course_key = SampleCourseFactory.create().id
self.course_usage_key = self.store.make_course_usage_key(self.course_key)
self.block_structure = BlockStructureFactory.create_from_modulestore(self.course_usage_key, self.store)
def test_transform(self):
# collect phase
BlockCountsTransformer.collect(self.block_structure)
self.block_structure._collect_requested_xblock_fields()
# transform phase
BlockCountsTransformer(['problem', 'chapter']).transform(usage_info=None, block_structure=self.block_structure)
# block_counts
chapter_x_key = self.course_key.make_usage_key('chapter', 'chapter_x')
block_counts_for_chapter_x = self.block_structure.get_transformer_block_data(
chapter_x_key, BlockCountsTransformer,
)
block_counts_for_course = self.block_structure.get_transformer_block_data(
self.course_usage_key, BlockCountsTransformer,
)
# verify count of chapters
self.assertEquals(block_counts_for_course['chapter'], 2)
# verify count of problems
self.assertEquals(block_counts_for_course['problem'], 6)
self.assertEquals(block_counts_for_chapter_x['problem'], 3)
# verify other block types are not counted
for block_type in ['course', 'html', 'video']:
self.assertNotIn(block_type, block_counts_for_course)
self.assertNotIn(block_type, block_counts_for_chapter_x)
"""
Tests for BlockDepthTransformer.
"""
# pylint: disable=protected-access
import ddt
from unittest import TestCase
from openedx.core.lib.block_cache.tests.test_utils import ChildrenMapTestMixin
from openedx.core.lib.block_cache.block_structure import BlockStructureModulestoreData
from ..block_depth import BlockDepthTransformer
@ddt.ddt
class BlockDepthTransformerTestCase(TestCase, ChildrenMapTestMixin):
"""
Test behavior of BlockDepthTransformer
"""
@ddt.data(
(0, [], [], []),
(0, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, [[], [], [], [], []], [1, 2, 3, 4]),
(1, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, [[1, 2], [], [], [], []], [3, 4]),
(2, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, []),
(3, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, []),
(None, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, ChildrenMapTestMixin.SIMPLE_CHILDREN_MAP, []),
(0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[], [], [], [], [], [], []], [1, 2, 3, 4, 5, 6]),
(1, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [], [], [], [], [], []], [3, 4, 5, 6]),
(2, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [], [], [], []], [5, 6]),
(3, ChildrenMapTestMixin.DAG_CHILDREN_MAP, ChildrenMapTestMixin.DAG_CHILDREN_MAP, []),
(4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, ChildrenMapTestMixin.DAG_CHILDREN_MAP, []),
(None, ChildrenMapTestMixin.DAG_CHILDREN_MAP, ChildrenMapTestMixin.DAG_CHILDREN_MAP, []),
)
@ddt.unpack
def test_block_depth(self, block_depth, children_map, transformed_children_map, missing_blocks):
block_structure = self.create_block_structure(BlockStructureModulestoreData, children_map)
BlockDepthTransformer(block_depth).transform(usage_info=None, block_structure=block_structure)
block_structure._prune_unreachable()
self.assert_block_structure(block_structure, transformed_children_map, missing_blocks)
# pylint: disable=protected-access
"""
Tests for BlockNavigationTransformer.
"""
import ddt
from unittest import TestCase
from lms.djangoapps.course_api.blocks.transformers.block_depth import BlockDepthTransformer
from lms.djangoapps.course_api.blocks.transformers.navigation import BlockNavigationTransformer
from openedx.core.lib.block_cache.tests.test_utils import ChildrenMapTestMixin
from openedx.core.lib.block_cache.block_structure import BlockStructureModulestoreData
from openedx.core.lib.block_cache.block_structure_factory import BlockStructureFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import SampleCourseFactory
from xmodule.modulestore import ModuleStoreEnum
@ddt.ddt
class BlockNavigationTransformerTestCase(TestCase, ChildrenMapTestMixin):
"""
Course-agnostic test class for testing the Navigation transformer.
"""
@ddt.data(
(0, 0, [], []),
(0, 0, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[], [], [], []]),
(None, 0, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1, 2, 3], [], [], []]),
(None, 1, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2, 3], [], []]),
(None, 2, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [3], []]),
(None, 3, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [3], []]),
(None, 4, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [3], []]),
(1, 4, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [], [], []]),
(2, 4, ChildrenMapTestMixin.LINEAR_CHILDREN_MAP, [[1], [2], [], []]),
(0, 0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[], [], [], [], [], [], []]),
(0, 0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[], [], [], [], [], [], []]),
(None, 0, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2, 3, 4, 5, 6], [], [], [], [], [], []]),
(None, 1, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3, 5, 6], [3, 4, 5, 6], [], [], [], []]),
(None, 2, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [5, 6], [], [], []]),
(None, 3, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [5, 6], [], [], []]),
(None, 4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [5, 6], [], [], []]),
(1, 4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [], [], [], [], [], []]),
(2, 4, ChildrenMapTestMixin.DAG_CHILDREN_MAP, [[1, 2], [3], [3, 4], [], [], [], []]),
)
@ddt.unpack
def test_navigation(self, depth, nav_depth, children_map, expected_nav_map):
block_structure = self.create_block_structure(BlockStructureModulestoreData, children_map)
BlockDepthTransformer(depth).transform(usage_info=None, block_structure=block_structure)
BlockNavigationTransformer(nav_depth).transform(usage_info=None, block_structure=block_structure)
block_structure._prune_unreachable()
for block_key, expected_nav in enumerate(expected_nav_map):
self.assertSetEqual(
set(unicode(block) for block in expected_nav),
set(
block_structure.get_transformer_block_field(
block_key,
BlockNavigationTransformer,
BlockNavigationTransformer.BLOCK_NAVIGATION,
[]
)
),
)
class BlockNavigationTransformerCourseTestCase(ModuleStoreTestCase):
"""
Uses SampleCourseFactory and Modulestore to test the Navigation transformer,
specifically to test enforcement of the hide_from_toc field
"""
def test_hide_from_toc(self):
course_key = SampleCourseFactory.create().id
course_usage_key = self.store.make_course_usage_key(course_key)
# hide chapter_x from TOC
chapter_x_key = course_key.make_usage_key('chapter', 'chapter_x')
chapter_x = self.store.get_item(chapter_x_key)
chapter_x.hide_from_toc = True
self.store.update_item(chapter_x, ModuleStoreEnum.UserID.test)
block_structure = BlockStructureFactory.create_from_modulestore(course_usage_key, self.store)
# collect phase
BlockDepthTransformer.collect(block_structure)
BlockNavigationTransformer.collect(block_structure)
block_structure._collect_requested_xblock_fields()
self.assertTrue(block_structure.has_block(chapter_x_key))
# transform phase
BlockDepthTransformer().transform(usage_info=None, block_structure=block_structure)
BlockNavigationTransformer(0).transform(usage_info=None, block_structure=block_structure)
block_structure._prune_unreachable()
self.assertTrue(block_structure.has_block(chapter_x_key))
course_descendants = block_structure.get_transformer_block_field(
course_usage_key,
BlockNavigationTransformer,
BlockNavigationTransformer.BLOCK_NAVIGATION,
)
# chapter_y and its descendants should be included
for block_key in [
course_key.make_usage_key('chapter', 'chapter_y'),
course_key.make_usage_key('sequential', 'sequential_y1'),
course_key.make_usage_key('vertical', 'vertical_y1a'),
course_key.make_usage_key('problem', 'problem_y1a_1'),
]:
self.assertIn(unicode(block_key), course_descendants)
# chapter_x and its descendants should not be included
for block_key in [
chapter_x_key,
course_key.make_usage_key('sequential', 'sequential_x1'),
course_key.make_usage_key('vertical', 'vertical_x1a'),
course_key.make_usage_key('problem', 'problem_x1a_1'),
]:
self.assertNotIn(unicode(block_key), course_descendants)
"""
Tests for ProctoredExamTransformer.
"""
from mock import patch
import ddt
from edx_proctoring.api import (
create_exam,
create_exam_attempt,
update_attempt_status
)
from edx_proctoring.models import ProctoredExamStudentAttemptStatus
from edx_proctoring.runtime import set_runtime_service
from edx_proctoring.tests.test_services import MockCreditService
from lms.djangoapps.course_blocks.transformers.tests.test_helpers import CourseStructureTestCase
from student.tests.factories import CourseEnrollmentFactory
from ..proctored_exam import ProctoredExamTransformer
from ...api import get_course_blocks
@ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True})
class ProctoredExamTransformerTestCase(CourseStructureTestCase):
"""
Test behavior of ProctoredExamTransformer
"""
def setUp(self):
"""
Setup course structure and create user for split test transformer test.
"""
super(ProctoredExamTransformerTestCase, self).setUp()
# Set up proctored exam
# Build course.
self.course_hierarchy = self.get_course_hierarchy()
self.blocks = self.build_course(self.course_hierarchy)
self.course = self.blocks['course']
# Enroll user in course.
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
self.transformer = ProctoredExamTransformer()
def setup_proctored_exam(self, block, attempt_status, user_id):
"""
Test helper to configure the given block as a proctored exam.
"""
exam_id = create_exam(
course_id=unicode(block.location.course_key),
content_id=unicode(block.location),
exam_name='foo',
time_limit_mins=10,
is_proctored=True,
is_practice_exam=block.is_practice_exam,
)
set_runtime_service(
'credit',
MockCreditService(enrollment_mode='verified')
)
create_exam_attempt(exam_id, user_id, taking_as_proctored=True)
update_attempt_status(exam_id, user_id, attempt_status)
ALL_BLOCKS = ('course', 'A', 'B', 'C', 'TimedExam', 'D', 'E', 'PracticeExam', 'F', 'G')
def get_course_hierarchy(self):
"""
Get a course hierarchy to test with.
"""
# course
# / | \
# / | \
# A Exam1 Exam2
# / \ / \ / \
# / \ / \ / \
# B C D E F G
#
return [
{
'org': 'ProctoredExamTransformer',
'course': 'PE101F',
'run': 'test_run',
'#type': 'course',
'#ref': 'course',
},
{
'#type': 'sequential',
'#ref': 'A',
'#children': [
{'#type': 'vertical', '#ref': 'B'},
{'#type': 'vertical', '#ref': 'C'},
],
},
{
'#type': 'sequential',
'#ref': 'TimedExam',
'is_time_limited': True,
'is_proctored_enabled': True,
'is_practice_exam': False,
'#children': [
{'#type': 'vertical', '#ref': 'D'},
{'#type': 'vertical', '#ref': 'E'},
],
},
{
'#type': 'sequential',
'#ref': 'PracticeExam',
'is_time_limited': True,
'is_proctored_enabled': True,
'is_practice_exam': True,
'#children': [
{'#type': 'vertical', '#ref': 'F'},
{'#type': 'vertical', '#ref': 'G'},
],
},
]
def test_exam_not_created(self):
block_structure = get_course_blocks(
self.user,
self.course.location,
transformers={self.transformer},
)
self.assertEqual(
set(block_structure.get_block_keys()),
set(self.get_block_key_set(self.blocks, *self.ALL_BLOCKS)),
)
@ddt.data(
(
'TimedExam',
ProctoredExamStudentAttemptStatus.declined,
ALL_BLOCKS,
),
(
'TimedExam',
ProctoredExamStudentAttemptStatus.submitted,
('course', 'A', 'B', 'C', 'PracticeExam', 'F', 'G'),
),
(
'TimedExam',
ProctoredExamStudentAttemptStatus.rejected,
('course', 'A', 'B', 'C', 'PracticeExam', 'F', 'G'),
),
(
'PracticeExam',
ProctoredExamStudentAttemptStatus.declined,
ALL_BLOCKS,
),
(
'PracticeExam',
ProctoredExamStudentAttemptStatus.rejected,
('course', 'A', 'B', 'C', 'TimedExam', 'D', 'E'),
),
)
@ddt.unpack
def test_exam_created(self, exam_ref, attempt_status, expected_blocks):
self.setup_proctored_exam(self.blocks[exam_ref], attempt_status, self.user.id)
block_structure = get_course_blocks(
self.user,
self.course.location,
transformers={self.transformer},
)
self.assertEqual(
set(block_structure.get_block_keys()),
set(self.get_block_key_set(self.blocks, *expected_blocks)),
)
"""
Tests for StudentViewTransformer.
"""
# pylint: disable=protected-access
from openedx.core.lib.block_cache.block_structure_factory import BlockStructureFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import ToyCourseFactory
from ..student_view import StudentViewTransformer
class TestStudentViewTransformer(ModuleStoreTestCase):
"""
Test proper behavior for StudentViewTransformer
"""
def setUp(self):
super(TestStudentViewTransformer, self).setUp()
self.course_key = ToyCourseFactory.create().id
self.course_usage_key = self.store.make_course_usage_key(self.course_key)
self.block_structure = BlockStructureFactory.create_from_modulestore(self.course_usage_key, self.store)
def test_transform(self):
# collect phase
StudentViewTransformer.collect(self.block_structure)
self.block_structure._collect_requested_xblock_fields()
# transform phase
StudentViewTransformer('video').transform(usage_info=None, block_structure=self.block_structure)
# verify video data
video_block_key = self.course_key.make_usage_key('video', 'sample_video')
self.assertIsNotNone(
self.block_structure.get_transformer_block_field(
video_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA,
)
)
self.assertFalse(
self.block_structure.get_transformer_block_field(
video_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE,
)
)
# verify html data
html_block_key = self.course_key.make_usage_key('html', 'toyhtml')
self.assertIsNone(
self.block_structure.get_transformer_block_field(
html_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_DATA,
)
)
self.assertTrue(
self.block_structure.get_transformer_block_field(
html_block_key, StudentViewTransformer, StudentViewTransformer.STUDENT_VIEW_MULTI_DEVICE,
)
)
"""
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})
......@@ -658,8 +658,8 @@ class CourseBlocksAndNavigation(ListAPIView):
method, add the response from the 'student_view_json" method as the data for the block.
"""
if block_info.type in request_info.block_json:
if getattr(block_info.block, 'student_view_json', None):
block_info.value["block_json"] = block_info.block.student_view_json(
if getattr(block_info.block, 'student_view_data', None):
block_info.value["block_json"] = block_info.block.student_view_data(
context=request_info.block_json[block_info.type]
)
......
......@@ -869,7 +869,7 @@ class TestVideoDescriptorInitialization(BaseTestXmodule):
@ddt.ddt
class TestVideoDescriptorStudentViewJson(TestCase):
"""
Tests for the student_view_json method on VideoDescriptor.
Tests for the student_view_data method on VideoDescriptor.
"""
TEST_DURATION = 111.0
TEST_PROFILE = "mobile"
......@@ -914,15 +914,15 @@ class TestVideoDescriptorStudentViewJson(TestCase):
def get_result(self, allow_cache_miss=True):
"""
Returns the result from calling the video's student_view_json method.
Returns the result from calling the video's student_view_data method.
Arguments:
allow_cache_miss is passed in the context to the student_view_json method.
allow_cache_miss is passed in the context to the student_view_data method.
"""
context = {
"profiles": [self.TEST_PROFILE],
"allow_cache_miss": "True" if allow_cache_miss else "False"
}
return self.video.student_view_json(context)
return self.video.student_view_data(context)
def verify_result_with_fallback_url(self, result):
"""
......
......@@ -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