Commit 1a15bd7b by Nimisha Asthagiri

Merge pull request #8209 from edx/mobile/xblock-support

Course Blocks + Navigation API (feature flagged) for Mobile
parents 3ec22064 89ea8b31
...@@ -3,7 +3,7 @@ Utility library containing operations used/shared by multiple courseware modules ...@@ -3,7 +3,7 @@ Utility library containing operations used/shared by multiple courseware modules
""" """
def yield_dynamic_descriptor_descendents(descriptor, module_creator): # pylint: disable=invalid-name def yield_dynamic_descriptor_descendants(descriptor, user_id, module_creator): # pylint: disable=invalid-name
""" """
This returns all of the descendants of a descriptor. If the descriptor This returns all of the descendants of a descriptor. If the descriptor
has dynamic children, the module will be created using module_creator has dynamic children, the module will be created using module_creator
...@@ -13,16 +13,20 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator): # pylint: ...@@ -13,16 +13,20 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator): # pylint:
while len(stack) > 0: while len(stack) > 0:
next_descriptor = stack.pop() next_descriptor = stack.pop()
stack.extend(get_dynamic_descriptor_children(next_descriptor, module_creator)) stack.extend(get_dynamic_descriptor_children(next_descriptor, user_id, module_creator))
yield next_descriptor yield next_descriptor
def get_dynamic_descriptor_children(descriptor, module_creator, usage_key_filter=None): def get_dynamic_descriptor_children(descriptor, user_id, module_creator=None, usage_key_filter=None):
""" """
Returns the children of the given descriptor, while supporting descriptors with dynamic children. Returns the children of the given descriptor, while supporting descriptors with dynamic children.
""" """
module_children = [] module_children = []
if descriptor.has_dynamic_children(): if descriptor.has_dynamic_children():
# do not rebind the module if it's already bound to a user.
if descriptor.scope_ids.user_id and user_id == descriptor.scope_ids.user_id:
module = descriptor
else:
module = module_creator(descriptor) module = module_creator(descriptor)
if module is not None: if module is not None:
module_children = module.get_child_descriptors() module_children = module.get_child_descriptors()
......
...@@ -527,6 +527,13 @@ class LoncapaProblem(object): ...@@ -527,6 +527,13 @@ class LoncapaProblem(object):
log.warning("Could not find matching input for id: %s", input_id) log.warning("Could not find matching input for id: %s", input_id)
return {} return {}
@property
def has_responsive_ui(self):
"""
Returns whether this capa problem has support for responsive UI.
"""
return all(responder.has_responsive_ui for responder in self.responders.values())
# ======= Private Methods Below ======== # ======= Private Methods Below ========
def _process_includes(self): def _process_includes(self):
......
...@@ -138,6 +138,11 @@ class LoncapaResponse(object): ...@@ -138,6 +138,11 @@ class LoncapaResponse(object):
allowed_inputfields = [] allowed_inputfields = []
required_attributes = [] required_attributes = []
# Overridable field that specifies whether this capa response type has support for
# responsive UI, for rendering on devices of different sizes and shapes.
# By default, we set this to False, allowing subclasses to override as appropriate.
has_responsive_ui = False
def __init__(self, xml, inputfields, context, system): def __init__(self, xml, inputfields, context, system):
""" """
Init is passed the following arguments: Init is passed the following arguments:
...@@ -692,6 +697,7 @@ class ChoiceResponse(LoncapaResponse): ...@@ -692,6 +697,7 @@ class ChoiceResponse(LoncapaResponse):
max_inputfields = 1 max_inputfields = 1
allowed_inputfields = ['checkboxgroup', 'radiogroup'] allowed_inputfields = ['checkboxgroup', 'radiogroup']
correct_choices = None correct_choices = None
has_responsive_ui = True
def setup_response(self): def setup_response(self):
...@@ -763,6 +769,7 @@ class MultipleChoiceResponse(LoncapaResponse): ...@@ -763,6 +769,7 @@ class MultipleChoiceResponse(LoncapaResponse):
max_inputfields = 1 max_inputfields = 1
allowed_inputfields = ['choicegroup'] allowed_inputfields = ['choicegroup']
correct_choices = None correct_choices = None
has_responsive_ui = True
def setup_response(self): def setup_response(self):
# call secondary setup for MultipleChoice questions, to set name # call secondary setup for MultipleChoice questions, to set name
...@@ -1084,6 +1091,7 @@ class OptionResponse(LoncapaResponse): ...@@ -1084,6 +1091,7 @@ class OptionResponse(LoncapaResponse):
hint_tag = 'optionhint' hint_tag = 'optionhint'
allowed_inputfields = ['optioninput'] allowed_inputfields = ['optioninput']
answer_fields = None answer_fields = None
has_responsive_ui = True
def setup_response(self): def setup_response(self):
self.answer_fields = self.inputfields self.answer_fields = self.inputfields
...@@ -1136,6 +1144,7 @@ class NumericalResponse(LoncapaResponse): ...@@ -1136,6 +1144,7 @@ class NumericalResponse(LoncapaResponse):
allowed_inputfields = ['textline', 'formulaequationinput'] allowed_inputfields = ['textline', 'formulaequationinput']
required_attributes = ['answer'] required_attributes = ['answer']
max_inputfields = 1 max_inputfields = 1
has_responsive_ui = True
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.correct_answer = '' self.correct_answer = ''
...@@ -1338,6 +1347,7 @@ class StringResponse(LoncapaResponse): ...@@ -1338,6 +1347,7 @@ class StringResponse(LoncapaResponse):
required_attributes = ['answer'] required_attributes = ['answer']
max_inputfields = 1 max_inputfields = 1
correct_answer = [] correct_answer = []
has_responsive_ui = True
def setup_response_backward(self): def setup_response_backward(self):
self.correct_answer = [ self.correct_answer = [
......
...@@ -187,6 +187,13 @@ class CapaDescriptor(CapaFields, RawDescriptor): ...@@ -187,6 +187,13 @@ class CapaDescriptor(CapaFields, RawDescriptor):
registered_tags = responsetypes.registry.registered_tags() registered_tags = responsetypes.registry.registered_tags()
return set([node.tag for node in tree.iter() if node.tag in registered_tags]) return set([node.tag for node in tree.iter() if node.tag in registered_tags])
@property
def has_responsive_ui(self):
"""
Returns whether this module has support for responsive UI.
"""
return self.lcp.has_responsive_ui
def index_dictionary(self): def index_dictionary(self):
""" """
Return dictionary prepared with module content and type for indexing. Return dictionary prepared with module content and type for indexing.
......
...@@ -95,6 +95,7 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: d ...@@ -95,6 +95,7 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: d
module_class = HtmlModule module_class = HtmlModule
filename_extension = "xml" filename_extension = "xml"
template_dir_name = "html" template_dir_name = "html"
has_responsive_ui = True
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
js_module_name = "HTMLEditingDescriptor" js_module_name = "HTMLEditingDescriptor"
......
...@@ -7,13 +7,11 @@ import logging ...@@ -7,13 +7,11 @@ import logging
import re import re
import json import json
import datetime import datetime
from uuid import uuid4
from pytz import UTC from pytz import UTC
from collections import namedtuple, defaultdict from collections import defaultdict
import collections import collections
from contextlib import contextmanager from contextlib import contextmanager
import functools
import threading import threading
from operator import itemgetter from operator import itemgetter
from sortedcontainers import SortedListWithKey from sortedcontainers import SortedListWithKey
...@@ -27,8 +25,6 @@ from xmodule.errortracker import make_error_tracker ...@@ -27,8 +25,6 @@ from xmodule.errortracker import make_error_tracker
from xmodule.assetstore import AssetMetadata from xmodule.assetstore import AssetMetadata
from opaque_keys.edx.keys import CourseKey, UsageKey, AssetKey from opaque_keys.edx.keys import CourseKey, UsageKey, AssetKey
from opaque_keys.edx.locations import Location # For import backwards compatibility from opaque_keys.edx.locations import Location # For import backwards compatibility
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xblock.runtime import Mixologist from xblock.runtime import Mixologist
from xblock.core import XBlock from xblock.core import XBlock
...@@ -1195,41 +1191,6 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead): ...@@ -1195,41 +1191,6 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
raise ValueError(u"Cannot set default store to type {}".format(store_type)) raise ValueError(u"Cannot set default store to type {}".format(store_type))
yield yield
@staticmethod
def memoize_request_cache(func):
"""
Memoize a function call results on the request_cache if there's one. Creates the cache key by
joining the unicode of all the args with &; so, if your arg may use the default &, it may
have false hits
"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
"""
Wraps a method to memoize results.
"""
if self.request_cache:
cache_key = '&'.join([hashvalue(arg) for arg in args])
if cache_key in self.request_cache.data.setdefault(func.__name__, {}):
return self.request_cache.data[func.__name__][cache_key]
result = func(self, *args, **kwargs)
self.request_cache.data[func.__name__][cache_key] = result
return result
else:
return func(self, *args, **kwargs)
return wrapper
def hashvalue(arg):
"""
If arg is an xblock, use its location. otherwise just turn it into a string
"""
if isinstance(arg, XBlock):
return unicode(arg.location)
else:
return unicode(arg)
# pylint: disable=abstract-method # pylint: disable=abstract-method
class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite): class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
......
...@@ -915,6 +915,9 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -915,6 +915,9 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
services["user"] = self.user_service services["user"] = self.user_service
services["settings"] = SettingsService() services["settings"] = SettingsService()
if self.request_cache:
services["request_cache"] = self.request_cache
system = CachingDescriptorSystem( system = CachingDescriptorSystem(
modulestore=self, modulestore=self,
course_key=course_key, course_key=course_key,
......
...@@ -10,6 +10,7 @@ import pymongo ...@@ -10,6 +10,7 @@ import pymongo
import logging import logging
from opaque_keys.edx.locations import Location from opaque_keys.edx.locations import Location
from openedx.core.lib.cache_utils import memoize_in_request_cache
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ( from xmodule.modulestore.exceptions import (
...@@ -634,7 +635,7 @@ class DraftModuleStore(MongoModuleStore): ...@@ -634,7 +635,7 @@ class DraftModuleStore(MongoModuleStore):
bulk_record.dirty = True bulk_record.dirty = True
self.collection.remove({'_id': {'$in': to_be_deleted}}, safe=self.collection.safe) self.collection.remove({'_id': {'$in': to_be_deleted}}, safe=self.collection.safe)
@MongoModuleStore.memoize_request_cache @memoize_in_request_cache('request_cache')
def has_changes(self, xblock): def has_changes(self, xblock):
""" """
Check if the subtree rooted at xblock has any drafts and thus may possibly have changes Check if the subtree rooted at xblock has any drafts and thus may possibly have changes
......
...@@ -671,6 +671,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): ...@@ -671,6 +671,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
if user_service is not None: if user_service is not None:
self.services["user"] = user_service self.services["user"] = user_service
if self.request_cache is not None:
self.services["request_cache"] = self.request_cache
self.signal_handler = signal_handler self.signal_handler = signal_handler
def close_connections(self): def close_connections(self):
......
...@@ -250,9 +250,10 @@ class VideoStudentViewHandlers(object): ...@@ -250,9 +250,10 @@ class VideoStudentViewHandlers(object):
response.content_type = Transcript.mime_types['sjson'] response.content_type = Transcript.mime_types['sjson']
elif dispatch == 'download': elif dispatch == 'download':
lang = request.GET.get('lang', None)
try: try:
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript( transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(
transcripts, transcript_format=self.transcript_download_format transcripts, transcript_format=self.transcript_download_format, lang=lang
) )
except (NotFoundError, ValueError, KeyError, UnicodeDecodeError): except (NotFoundError, ValueError, KeyError, UnicodeDecodeError):
log.debug("Video@download exception") log.debug("Video@download exception")
......
...@@ -20,12 +20,12 @@ import logging ...@@ -20,12 +20,12 @@ import logging
import random import random
from collections import OrderedDict from collections import OrderedDict
from operator import itemgetter from operator import itemgetter
from lxml import etree from lxml import etree
from pkg_resources import resource_string from pkg_resources import resource_string
from django.conf import settings from django.conf import settings
from openedx.core.lib.cache_utils import memoize_in_request_cache
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import ScopeIds from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData from xblock.runtime import KvsFieldData
...@@ -329,6 +329,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers, ...@@ -329,6 +329,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
return self.system.render_template('video.html', context) return self.system.render_template('video.html', context)
@XBlock.wants("request_cache")
@XBlock.wants("settings") @XBlock.wants("settings")
class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers,
TabsEditingDescriptor, EmptyDataRawDescriptor): TabsEditingDescriptor, EmptyDataRawDescriptor):
...@@ -722,7 +723,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -722,7 +723,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if self.sub: if self.sub:
_update_transcript_for_index() _update_transcript_for_index()
# check to see if there are transcripts in other languages besides default transcript # Check to see if there are transcripts in other languages besides default transcript
if self.transcripts: if self.transcripts:
for language in self.transcripts.keys(): for language in self.transcripts.keys():
_update_transcript_for_index(language) _update_transcript_for_index(language)
...@@ -734,3 +735,79 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -734,3 +735,79 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
xblock_body["content_type"] = "Video" xblock_body["content_type"] = "Video"
return xblock_body return xblock_body
@property
def request_cache(self):
"""
Returns the request_cache from the runtime.
"""
return self.runtime.service(self, "request_cache")
@memoize_in_request_cache('request_cache')
def get_cached_val_data_for_course(self, video_profile_names, course_id):
"""
Returns the VAL data for the requested video profiles for the given course.
"""
return edxval_api.get_video_info_for_course_and_profiles(unicode(course_id), video_profile_names)
def student_view_json(self, context):
"""
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.
"""
# 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:
return {"only_on_web": True}
encoded_videos = {}
val_video_data = {}
# Check in VAL data first if edx_video_id exists
if self.edx_video_id:
video_profile_names = context.get("profiles", [])
# 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)
val_video_data = val_course_data.get(self.edx_video_id, {})
# Get the encoded videos if data from VAL is found
if val_video_data:
encoded_videos = val_video_data.get('profiles', {})
# If information for this edx_video_id is not found in the bulk course data, make a
# separate request for this individual edx_video_id, unless cache misses are disabled.
# This is useful/required for videos that don't have a course designated, such as the introductory video
# that is shared across many courses. However, this results in a separate database request so watch
# out for any performance hit if many such videos exist in a course. Set the 'allow_cache_miss' parameter
# to False to disable this fall back.
elif context.get("allow_cache_miss", "True").lower() == "true":
try:
val_video_data = edxval_api.get_video_info(self.edx_video_id)
# Unfortunately, the VAL API is inconsistent in how it returns the encodings, so remap here.
for enc_vid in val_video_data.pop('encoded_videos'):
encoded_videos[enc_vid['profile']] = {key: enc_vid[key] for key in ["url", "file_size"]}
except edxval_api.ValVideoNotFoundError:
pass
# Fall back to other video URLs in the video module if not found in VAL
if not encoded_videos:
video_url = self.html5_sources[0] if self.html5_sources else self.source
if video_url:
encoded_videos["fallback"] = {
"url": video_url,
"file_size": 0, # File size is unknown for fallback URLs
}
transcripts_info = self.get_transcripts_info()
transcripts = {
lang: self.runtime.handler_url(self, 'transcript', 'download', query="lang=" + lang, thirdparty=True)
for lang in self.available_translations(transcripts_info, verify_assets=False)
}
return {
"only_on_web": self.only_on_web,
"duration": val_video_data.get('duration', None),
"transcripts": transcripts,
"encoded_videos": encoded_videos,
}
...@@ -14,5 +14,33 @@ urlpatterns = patterns( ...@@ -14,5 +14,33 @@ urlpatterns = patterns(
url(r'^courses/$', views.CourseList.as_view(), name='list'), url(r'^courses/$', views.CourseList.as_view(), name='list'),
url(r'^courses/{}/$'.format(COURSE_ID_PATTERN), views.CourseDetail.as_view(), name='detail'), url(r'^courses/{}/$'.format(COURSE_ID_PATTERN), views.CourseDetail.as_view(), name='detail'),
url(r'^course_structures/{}/$'.format(COURSE_ID_PATTERN), views.CourseStructure.as_view(), name='structure'), url(r'^course_structures/{}/$'.format(COURSE_ID_PATTERN), views.CourseStructure.as_view(), name='structure'),
url(r'^grading_policies/{}/$'.format(COURSE_ID_PATTERN), views.CourseGradingPolicy.as_view(), name='grading_policy') url(
r'^grading_policies/{}/$'.format(COURSE_ID_PATTERN),
views.CourseGradingPolicy.as_view(),
name='grading_policy'
),
) )
if settings.FEATURES.get('ENABLE_COURSE_BLOCKS_NAVIGATION_API'):
# TODO (MA-789) This endpoint still needs to be approved by the arch council.
# TODO (MA-704) This endpoint still needs to be made performant.
urlpatterns += (
url(
r'^courses/{}/blocks/$'.format(COURSE_ID_PATTERN),
views.CourseBlocksAndNavigation.as_view(),
{'return_blocks': True, 'return_nav': False},
name='blocks'
),
url(
r'^courses/{}/navigation/$'.format(COURSE_ID_PATTERN),
views.CourseBlocksAndNavigation.as_view(),
{'return_blocks': False, 'return_nav': True},
name='navigation'
),
url(
r'^courses/{}/blocks\+navigation/$'.format(COURSE_ID_PATTERN),
views.CourseBlocksAndNavigation.as_view(),
{'return_blocks': True, 'return_nav': True},
name='blocks+navigation'
),
)
...@@ -9,7 +9,7 @@ from courseware.models import StudentModule ...@@ -9,7 +9,7 @@ from courseware.models import StudentModule
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from student.models import EntranceExamConfiguration from student.models import EntranceExamConfiguration
from util.milestones_helpers import get_required_content from util.milestones_helpers import get_required_content
from util.module_utils import yield_dynamic_descriptor_descendents from util.module_utils import yield_dynamic_descriptor_descendants
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -147,8 +147,9 @@ def get_entrance_exam_score(request, course): ...@@ -147,8 +147,9 @@ def get_entrance_exam_score(request, course):
course.id course.id
) )
exam_module_generators = yield_dynamic_descriptor_descendents( exam_module_generators = yield_dynamic_descriptor_descendants(
exam_descriptor, exam_descriptor,
request.user.id,
inner_get_module inner_get_module
) )
exam_modules = [module for module in exam_module_generators] exam_modules = [module for module in exam_module_generators]
......
...@@ -15,7 +15,7 @@ import dogstats_wrapper as dog_stats_api ...@@ -15,7 +15,7 @@ import dogstats_wrapper as dog_stats_api
from courseware import courses from courseware import courses
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from util.module_utils import yield_dynamic_descriptor_descendents from util.module_utils import yield_dynamic_descriptor_descendants
from xmodule import graders from xmodule import graders
from xmodule.graders import Score from xmodule.graders import Score
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -209,7 +209,9 @@ def _grade(student, request, course, keep_raw_scores): ...@@ -209,7 +209,9 @@ def _grade(student, request, course, keep_raw_scores):
field_data_cache = FieldDataCache([descriptor], course.id, student) field_data_cache = FieldDataCache([descriptor], course.id, student)
return get_module_for_descriptor(student, request, descriptor, field_data_cache, course.id) return get_module_for_descriptor(student, request, descriptor, field_data_cache, course.id)
for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module): for module_descriptor in yield_dynamic_descriptor_descendants(
section_descriptor, student.id, create_module
):
(correct, total) = get_score( (correct, total) = get_score(
course.id, student, module_descriptor, create_module, scores_cache=submissions_scores course.id, student, module_descriptor, create_module, scores_cache=submissions_scores
...@@ -364,7 +366,9 @@ def _progress_summary(student, request, course): ...@@ -364,7 +366,9 @@ def _progress_summary(student, request, course):
module_creator = section_module.xmodule_runtime.get_module module_creator = section_module.xmodule_runtime.get_module
for module_descriptor in yield_dynamic_descriptor_descendents(section_module, module_creator): for module_descriptor in yield_dynamic_descriptor_descendants(
section_module, student.id, module_creator
):
course_id = course.id course_id = course.id
(correct, total) = get_score( (correct, total) = get_score(
course_id, student, module_descriptor, module_creator, scores_cache=submissions_scores course_id, student, module_descriptor, module_creator, scores_cache=submissions_scores
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Video xmodule tests in mongo.""" """Video xmodule tests in mongo."""
import ddt
import itertools
import json import json
from collections import OrderedDict from collections import OrderedDict
...@@ -13,7 +15,7 @@ from django.test.utils import override_settings ...@@ -13,7 +15,7 @@ from django.test.utils import override_settings
from xmodule.video_module import VideoDescriptor, bumper_utils, video_utils from xmodule.video_module import VideoDescriptor, bumper_utils, video_utils
from xmodule.x_module import STUDENT_VIEW from xmodule.x_module import STUDENT_VIEW
from xmodule.tests.test_video import VideoDescriptorTestBase from xmodule.tests.test_video import VideoDescriptorTestBase, instantiate_descriptor
from xmodule.tests.test_import import DummySystem from xmodule.tests.test_import import DummySystem
from edxval.api import ( from edxval.api import (
...@@ -861,6 +863,119 @@ class TestVideoDescriptorInitialization(BaseTestXmodule): ...@@ -861,6 +863,119 @@ class TestVideoDescriptorInitialization(BaseTestXmodule):
self.assertFalse(self.item_descriptor.download_video) self.assertFalse(self.item_descriptor.download_video)
@ddt.ddt
class TestVideoDescriptorStudentViewJson(TestCase):
"""
Tests for the student_view_json method on VideoDescriptor.
"""
TEST_DURATION = 111.0
TEST_PROFILE = "mobile"
TEST_SOURCE_URL = "http://www.example.com/source.mp4"
TEST_LANGUAGE = "ge"
TEST_ENCODED_VIDEO = {
'profile': TEST_PROFILE,
'bitrate': 333,
'url': 'http://example.com/video',
'file_size': 222,
}
TEST_EDX_VIDEO_ID = 'test_edx_video_id'
def setUp(self):
super(TestVideoDescriptorStudentViewJson, self).setUp()
sample_xml = (
"<video display_name='Test Video'> " +
"<source src='" + self.TEST_SOURCE_URL + "'/> " +
"<transcript language='" + self.TEST_LANGUAGE + "' src='german_translation.srt' /> " +
"</video>"
)
self.transcript_url = "transcript_url"
self.video = instantiate_descriptor(data=sample_xml)
self.video.runtime.handler_url = Mock(return_value=self.transcript_url)
def setup_val_video(self, associate_course_in_val=False):
"""
Creates a video entry in VAL.
Arguments:
associate_course - If True, associates the test course with the video in VAL.
"""
create_profile('mobile')
create_video({
'edx_video_id': self.TEST_EDX_VIDEO_ID,
'client_video_id': 'test_client_video_id',
'duration': self.TEST_DURATION,
'status': 'dummy',
'encoded_videos': [self.TEST_ENCODED_VIDEO],
'courses': [self.video.location.course_key] if associate_course_in_val else [],
})
self.val_video = get_video_info(self.TEST_EDX_VIDEO_ID) # pylint: disable=attribute-defined-outside-init
def get_result(self, allow_cache_miss=True):
"""
Returns the result from calling the video's student_view_json method.
Arguments:
allow_cache_miss is passed in the context to the student_view_json method.
"""
context = {
"profiles": [self.TEST_PROFILE],
"allow_cache_miss": "True" if allow_cache_miss else "False"
}
return self.video.student_view_json(context)
def verify_result_with_fallback_url(self, result):
"""
Verifies the result is as expected when returning "fallback" video data (not from VAL).
"""
self.assertDictEqual(
result,
{
"only_on_web": False,
"duration": None,
"transcripts": {self.TEST_LANGUAGE: self.transcript_url},
"encoded_videos": {"fallback": {"url": self.TEST_SOURCE_URL, "file_size": 0}},
}
)
def verify_result_with_val_profile(self, result):
"""
Verifies the result is as expected when returning video data from VAL.
"""
self.assertDictContainsSubset(
result.pop("encoded_videos")[self.TEST_PROFILE],
self.TEST_ENCODED_VIDEO,
)
self.assertDictEqual(
result,
{
"only_on_web": False,
"duration": self.TEST_DURATION,
"transcripts": {self.TEST_LANGUAGE: self.transcript_url},
}
)
def test_only_on_web(self):
self.video.only_on_web = True
result = self.get_result()
self.assertDictEqual(result, {"only_on_web": True})
def test_no_edx_video_id(self):
result = self.get_result()
self.verify_result_with_fallback_url(result)
@ddt.data(
*itertools.product([True, False], [True, False], [True, False])
)
@ddt.unpack
def test_with_edx_video_id(self, allow_cache_miss, video_exists_in_val, associate_course_in_val):
self.video.edx_video_id = self.TEST_EDX_VIDEO_ID
if video_exists_in_val:
self.setup_val_video(associate_course_in_val)
result = self.get_result(allow_cache_miss)
if video_exists_in_val and (associate_course_in_val or allow_cache_miss):
self.verify_result_with_val_profile(result)
else:
self.verify_result_with_fallback_url(result)
@attr('shard_1') @attr('shard_1')
class VideoDescriptorTest(TestCase, VideoDescriptorTestBase): class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
""" """
......
...@@ -86,6 +86,7 @@ class BlockOutline(object): ...@@ -86,6 +86,7 @@ class BlockOutline(object):
if curr_block.has_children: if curr_block.has_children:
children = get_dynamic_descriptor_children( children = get_dynamic_descriptor_children(
curr_block, curr_block,
self.request.user.id,
create_module, create_module,
usage_key_filter=parent_or_requested_block_type usage_key_filter=parent_or_requested_block_type
) )
......
...@@ -321,7 +321,10 @@ FEATURES = { ...@@ -321,7 +321,10 @@ FEATURES = {
# ENABLE_OAUTH2_PROVIDER to True # ENABLE_OAUTH2_PROVIDER to True
'ENABLE_MOBILE_REST_API': False, 'ENABLE_MOBILE_REST_API': False,
'ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES': False, 'ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES': False,
# Enable APIs required for xBlocks on Mobile, and supported in general
'ENABLE_RENDER_XBLOCK_API': False, 'ENABLE_RENDER_XBLOCK_API': False,
'ENABLE_COURSE_BLOCKS_NAVIGATION_API': False,
# Enable the combined login/registration form # Enable the combined login/registration form
'ENABLE_COMBINED_LOGIN_REGISTRATION': False, 'ENABLE_COMBINED_LOGIN_REGISTRATION': False,
......
...@@ -268,6 +268,8 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True ...@@ -268,6 +268,8 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
FEATURES['ENABLE_MOBILE_REST_API'] = True FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'] = True FEATURES['ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
FEATURES['ENABLE_COURSE_BLOCKS_NAVIGATION_API'] = True
FEATURES['ENABLE_RENDER_XBLOCK_API'] = True
###################### Payment ##############################3 ###################### Payment ##############################3
# Enable fake payment processing page # Enable fake payment processing page
......
"""
Utilities related to caching.
"""
import functools
from xblock.core import XBlock
def memoize_in_request_cache(request_cache_attr_name=None):
"""
Memoize a method call's results in the request_cache if there's one. Creates the cache key by
joining the unicode of all the args with &; so, if your arg may use the default &, it may
have false hits.
Arguments:
request_cache_attr_name - The name of the field or property in this method's containing
class that stores the request_cache.
"""
def _decorator(func):
"""Outer method decorator."""
@functools.wraps(func)
def _wrapper(self, *args, **kwargs):
"""
Wraps a method to memoize results.
"""
request_cache = getattr(self, request_cache_attr_name, None)
if request_cache:
cache_key = '&'.join([hashvalue(arg) for arg in args])
if cache_key in request_cache.data.setdefault(func.__name__, {}):
return request_cache.data[func.__name__][cache_key]
result = func(self, *args, **kwargs)
request_cache.data[func.__name__][cache_key] = result
return result
else:
return func(self, *args, **kwargs)
return _wrapper
return _decorator
def hashvalue(arg):
"""
If arg is an xblock, use its location. otherwise just turn it into a string
"""
if isinstance(arg, XBlock):
return unicode(arg.location)
else:
return unicode(arg)
"""
Tests for cache_utils.py
"""
import ddt
from mock import MagicMock
from unittest import TestCase
from openedx.core.lib.cache_utils import memoize_in_request_cache
@ddt.ddt
class TestMemoizeInRequestCache(TestCase):
"""
Test the memoize_in_request_cache helper function.
"""
class TestCache(object):
"""
A test cache that provides a data dict for caching values, analogous to the request_cache.
"""
def __init__(self):
self.data = {}
def setUp(self):
super(TestMemoizeInRequestCache, self).setUp()
self.request_cache = self.TestCache()
@memoize_in_request_cache('request_cache')
def func_to_memoize(self, param):
"""
A test function whose results are to be memoized in the request_cache.
"""
return self.func_to_count(param)
@memoize_in_request_cache('request_cache')
def multi_param_func_to_memoize(self, param1, param2):
"""
A test function with multiple parameters whose results are to be memoized in the request_cache.
"""
return self.func_to_count(param1, param2)
def test_memoize_in_request_cache(self):
"""
Tests the memoize_in_request_cache decorator for both single-param and multiple-param functions.
"""
funcs_to_test = (
(self.func_to_memoize, ['foo'], ['bar']),
(self.multi_param_func_to_memoize, ['foo', 'foo2'], ['foo', 'foo3']),
)
for func_to_memoize, arg_list1, arg_list2 in funcs_to_test:
self.func_to_count = MagicMock() # pylint: disable=attribute-defined-outside-init
self.assertFalse(self.func_to_count.called)
func_to_memoize(*arg_list1)
self.func_to_count.assert_called_once_with(*arg_list1)
func_to_memoize(*arg_list1)
self.func_to_count.assert_called_once_with(*arg_list1)
for _ in range(10):
func_to_memoize(*arg_list1)
func_to_memoize(*arg_list2)
self.assertEquals(self.func_to_count.call_count, 2)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment