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