Merge pull request #8209 from edx/mobile/xblock-support
Course Blocks + Navigation API (feature flagged) for Mobile
This commit is contained in:
@@ -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
|
||||
has dynamic children, the module will be created using module_creator
|
||||
@@ -13,17 +13,21 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator): # pylint:
|
||||
|
||||
while len(stack) > 0:
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
module_children = []
|
||||
if descriptor.has_dynamic_children():
|
||||
module = module_creator(descriptor)
|
||||
# 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)
|
||||
if module is not None:
|
||||
module_children = module.get_child_descriptors()
|
||||
else:
|
||||
|
||||
@@ -527,6 +527,13 @@ class LoncapaProblem(object):
|
||||
log.warning("Could not find matching input for id: %s", input_id)
|
||||
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 ========
|
||||
|
||||
def _process_includes(self):
|
||||
|
||||
@@ -138,6 +138,11 @@ class LoncapaResponse(object):
|
||||
allowed_inputfields = []
|
||||
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):
|
||||
"""
|
||||
Init is passed the following arguments:
|
||||
@@ -692,6 +697,7 @@ class ChoiceResponse(LoncapaResponse):
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['checkboxgroup', 'radiogroup']
|
||||
correct_choices = None
|
||||
has_responsive_ui = True
|
||||
|
||||
def setup_response(self):
|
||||
|
||||
@@ -763,6 +769,7 @@ class MultipleChoiceResponse(LoncapaResponse):
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['choicegroup']
|
||||
correct_choices = None
|
||||
has_responsive_ui = True
|
||||
|
||||
def setup_response(self):
|
||||
# call secondary setup for MultipleChoice questions, to set name
|
||||
@@ -1084,6 +1091,7 @@ class OptionResponse(LoncapaResponse):
|
||||
hint_tag = 'optionhint'
|
||||
allowed_inputfields = ['optioninput']
|
||||
answer_fields = None
|
||||
has_responsive_ui = True
|
||||
|
||||
def setup_response(self):
|
||||
self.answer_fields = self.inputfields
|
||||
@@ -1136,6 +1144,7 @@ class NumericalResponse(LoncapaResponse):
|
||||
allowed_inputfields = ['textline', 'formulaequationinput']
|
||||
required_attributes = ['answer']
|
||||
max_inputfields = 1
|
||||
has_responsive_ui = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.correct_answer = ''
|
||||
@@ -1338,6 +1347,7 @@ class StringResponse(LoncapaResponse):
|
||||
required_attributes = ['answer']
|
||||
max_inputfields = 1
|
||||
correct_answer = []
|
||||
has_responsive_ui = True
|
||||
|
||||
def setup_response_backward(self):
|
||||
self.correct_answer = [
|
||||
|
||||
@@ -187,6 +187,13 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
registered_tags = responsetypes.registry.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):
|
||||
"""
|
||||
Return dictionary prepared with module content and type for indexing.
|
||||
|
||||
@@ -95,6 +95,7 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # pylint: d
|
||||
module_class = HtmlModule
|
||||
filename_extension = "xml"
|
||||
template_dir_name = "html"
|
||||
has_responsive_ui = True
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@@ -7,13 +7,11 @@ import logging
|
||||
import re
|
||||
import json
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pytz import UTC
|
||||
from collections import namedtuple, defaultdict
|
||||
from collections import defaultdict
|
||||
import collections
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
import threading
|
||||
from operator import itemgetter
|
||||
from sortedcontainers import SortedListWithKey
|
||||
@@ -27,8 +25,6 @@ from xmodule.errortracker import make_error_tracker
|
||||
from xmodule.assetstore import AssetMetadata
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey, AssetKey
|
||||
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.core import XBlock
|
||||
|
||||
@@ -1195,41 +1191,6 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
|
||||
raise ValueError(u"Cannot set default store to type {}".format(store_type))
|
||||
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
|
||||
class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
|
||||
|
||||
@@ -915,6 +915,9 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
services["user"] = self.user_service
|
||||
services["settings"] = SettingsService()
|
||||
|
||||
if self.request_cache:
|
||||
services["request_cache"] = self.request_cache
|
||||
|
||||
system = CachingDescriptorSystem(
|
||||
modulestore=self,
|
||||
course_key=course_key,
|
||||
|
||||
@@ -10,6 +10,7 @@ import pymongo
|
||||
import logging
|
||||
|
||||
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.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.exceptions import (
|
||||
@@ -634,7 +635,7 @@ class DraftModuleStore(MongoModuleStore):
|
||||
bulk_record.dirty = True
|
||||
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):
|
||||
"""
|
||||
Check if the subtree rooted at xblock has any drafts and thus may possibly have changes
|
||||
|
||||
@@ -671,6 +671,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
|
||||
if user_service is not None:
|
||||
self.services["user"] = user_service
|
||||
|
||||
if self.request_cache is not None:
|
||||
self.services["request_cache"] = self.request_cache
|
||||
|
||||
self.signal_handler = signal_handler
|
||||
|
||||
def close_connections(self):
|
||||
|
||||
@@ -250,9 +250,10 @@ class VideoStudentViewHandlers(object):
|
||||
response.content_type = Transcript.mime_types['sjson']
|
||||
|
||||
elif dispatch == 'download':
|
||||
lang = request.GET.get('lang', None)
|
||||
try:
|
||||
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):
|
||||
log.debug("Video@download exception")
|
||||
|
||||
@@ -20,12 +20,12 @@ import logging
|
||||
import random
|
||||
from collections import OrderedDict
|
||||
from operator import itemgetter
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.core.lib.cache_utils import memoize_in_request_cache
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import ScopeIds
|
||||
from xblock.runtime import KvsFieldData
|
||||
@@ -329,6 +329,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
|
||||
return self.system.render_template('video.html', context)
|
||||
|
||||
|
||||
@XBlock.wants("request_cache")
|
||||
@XBlock.wants("settings")
|
||||
class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers,
|
||||
TabsEditingDescriptor, EmptyDataRawDescriptor):
|
||||
@@ -722,7 +723,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
|
||||
if self.sub:
|
||||
_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:
|
||||
for language in self.transcripts.keys():
|
||||
_update_transcript_for_index(language)
|
||||
@@ -734,3 +735,79 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
|
||||
xblock_body["content_type"] = "Video"
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user