The node_path attribute & constructor argument of the ModuleSystem is deprecated without any replacement service or fallback as there doesn't seem to be any core blocks using it. It also removes the references to node_path from the LMS settings, the LoncapaModuleSystem and the XBlock runtime shim. Co-authored-by: Agrendalath <piotr@surowiec.it>
1862 lines
68 KiB
Python
1862 lines
68 KiB
Python
# lint-amnesty, pylint: disable=missing-module-docstring
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
import warnings
|
|
from collections import namedtuple
|
|
from functools import partial
|
|
|
|
import yaml
|
|
|
|
from lazy import lazy
|
|
from lxml import etree
|
|
from opaque_keys.edx.asides import AsideDefinitionKeyV2, AsideUsageKeyV2
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from pkg_resources import resource_exists, resource_isdir, resource_listdir, resource_string
|
|
from web_fragments.fragment import Fragment
|
|
from webob import Response
|
|
from webob.multidict import MultiDict
|
|
from xblock.core import XBlock, XBlockAside
|
|
from xblock.fields import (
|
|
Dict,
|
|
Float,
|
|
Integer,
|
|
List,
|
|
Reference,
|
|
ReferenceList,
|
|
ReferenceValueDict,
|
|
Scope,
|
|
ScopeIds,
|
|
String,
|
|
UserScope
|
|
)
|
|
from xblock.runtime import IdGenerator, IdReader, Runtime
|
|
|
|
from xmodule import block_metadata_utils
|
|
from xmodule.errortracker import exc_info_to_str
|
|
from xmodule.exceptions import UndefinedContext
|
|
from xmodule.fields import RelativeTime
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
|
from openedx.core.djangolib.markup import HTML
|
|
|
|
from common.djangoapps.xblock_django.constants import (
|
|
ATTR_KEY_ANONYMOUS_USER_ID,
|
|
ATTR_KEY_REQUEST_COUNTRY_CODE,
|
|
ATTR_KEY_USER_ID,
|
|
ATTR_KEY_USER_IS_STAFF,
|
|
ATTR_KEY_USER_ROLE,
|
|
)
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
XMODULE_METRIC_NAME = 'edxapp.xmodule'
|
|
XMODULE_DURATION_METRIC_NAME = XMODULE_METRIC_NAME + '.duration'
|
|
XMODULE_METRIC_SAMPLE_RATE = 0.1
|
|
|
|
# Stats event sent to DataDog in order to determine if old XML parsing can be deprecated.
|
|
DEPRECATION_VSCOMPAT_EVENT = 'deprecation.vscompat'
|
|
|
|
# xblock view names
|
|
|
|
# This is the view that will be rendered to display the XBlock in the LMS.
|
|
# It will also be used to render the block in "preview" mode in Studio, unless
|
|
# the XBlock also implements author_view.
|
|
STUDENT_VIEW = 'student_view'
|
|
|
|
# This is the view that will be rendered to display the XBlock in the LMS for unenrolled learners.
|
|
# Implementations of this view should assume that a user and user data are not available.
|
|
PUBLIC_VIEW = 'public_view'
|
|
|
|
# An optional view of the XBlock similar to student_view, but with possible inline
|
|
# editing capabilities. This view differs from studio_view in that it should be as similar to student_view
|
|
# as possible. When previewing XBlocks within Studio, Studio will prefer author_view to student_view.
|
|
AUTHOR_VIEW = 'author_view'
|
|
|
|
# The view used to render an editor in Studio. The editor rendering can be completely different
|
|
# from the LMS student_view, and it is only shown when the author selects "Edit".
|
|
STUDIO_VIEW = 'studio_view'
|
|
|
|
# Views that present a "preview" view of an xblock (as opposed to an editing view).
|
|
PREVIEW_VIEWS = [STUDENT_VIEW, PUBLIC_VIEW, AUTHOR_VIEW]
|
|
|
|
DEFAULT_PUBLIC_VIEW_MESSAGE = (
|
|
'This content is only accessible to enrolled learners. '
|
|
'Sign in or register, and enroll in this course to view it.'
|
|
)
|
|
|
|
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
|
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
|
_ = lambda text: text
|
|
|
|
|
|
class OpaqueKeyReader(IdReader):
|
|
"""
|
|
IdReader for :class:`DefinitionKey` and :class:`UsageKey`s.
|
|
"""
|
|
def get_definition_id(self, usage_id):
|
|
"""Retrieve the definition that a usage is derived from.
|
|
|
|
Args:
|
|
usage_id: The id of the usage to query
|
|
|
|
Returns:
|
|
The `definition_id` the usage is derived from
|
|
"""
|
|
raise NotImplementedError("Specific Modulestores must implement get_definition_id")
|
|
|
|
def get_block_type(self, def_id):
|
|
"""Retrieve the block_type of a particular definition
|
|
|
|
Args:
|
|
def_id: The id of the definition to query
|
|
|
|
Returns:
|
|
The `block_type` of the definition
|
|
"""
|
|
return def_id.block_type
|
|
|
|
def get_usage_id_from_aside(self, aside_id):
|
|
"""
|
|
Retrieve the XBlock `usage_id` associated with this aside usage id.
|
|
|
|
Args:
|
|
aside_id: The usage id of the XBlockAside.
|
|
|
|
Returns:
|
|
The `usage_id` of the usage the aside is commenting on.
|
|
"""
|
|
return aside_id.usage_key
|
|
|
|
def get_definition_id_from_aside(self, aside_id):
|
|
"""
|
|
Retrieve the XBlock `definition_id` associated with this aside definition id.
|
|
|
|
Args:
|
|
aside_id: The usage id of the XBlockAside.
|
|
|
|
Returns:
|
|
The `definition_id` of the usage the aside is commenting on.
|
|
"""
|
|
return aside_id.definition_key
|
|
|
|
def get_aside_type_from_usage(self, aside_id):
|
|
"""
|
|
Retrieve the XBlockAside `aside_type` associated with this aside
|
|
usage id.
|
|
|
|
Args:
|
|
aside_id: The usage id of the XBlockAside.
|
|
|
|
Returns:
|
|
The `aside_type` of the aside.
|
|
"""
|
|
return aside_id.aside_type
|
|
|
|
def get_aside_type_from_definition(self, aside_id):
|
|
"""
|
|
Retrieve the XBlockAside `aside_type` associated with this aside
|
|
definition id.
|
|
|
|
Args:
|
|
aside_id: The definition id of the XBlockAside.
|
|
|
|
Returns:
|
|
The `aside_type` of the aside.
|
|
"""
|
|
return aside_id.aside_type
|
|
|
|
|
|
class AsideKeyGenerator(IdGenerator):
|
|
"""
|
|
An :class:`.IdGenerator` that only provides facilities for constructing new XBlockAsides.
|
|
"""
|
|
def create_aside(self, definition_id, usage_id, aside_type):
|
|
"""
|
|
Make a new aside definition and usage ids, indicating an :class:`.XBlockAside` of type `aside_type`
|
|
commenting on an :class:`.XBlock` usage `usage_id`
|
|
|
|
Returns:
|
|
(aside_definition_id, aside_usage_id)
|
|
"""
|
|
def_key = AsideDefinitionKeyV2(definition_id, aside_type)
|
|
usage_key = AsideUsageKeyV2(usage_id, aside_type)
|
|
return (def_key, usage_key)
|
|
|
|
def create_usage(self, def_id):
|
|
"""Make a usage, storing its definition id.
|
|
|
|
Returns the newly-created usage id.
|
|
"""
|
|
raise NotImplementedError("Specific Modulestores must provide implementations of create_usage")
|
|
|
|
def create_definition(self, block_type, slug=None):
|
|
"""Make a definition, storing its block type.
|
|
|
|
If `slug` is provided, it is a suggestion that the definition id
|
|
incorporate the slug somehow.
|
|
|
|
Returns the newly-created definition id.
|
|
|
|
"""
|
|
raise NotImplementedError("Specific Modulestores must provide implementations of create_definition")
|
|
|
|
|
|
def dummy_track(_event_type, _event):
|
|
pass
|
|
|
|
|
|
class HTMLSnippet:
|
|
"""
|
|
A base class defining an interface for an object that is able to present an
|
|
html snippet, along with associated javascript and css
|
|
"""
|
|
|
|
js = {}
|
|
js_module_name = None
|
|
|
|
preview_view_js = {}
|
|
studio_view_js = {}
|
|
|
|
css = {}
|
|
preview_view_css = {}
|
|
studio_view_css = {}
|
|
|
|
@classmethod
|
|
def get_javascript(cls):
|
|
"""
|
|
Return a dictionary containing some of the following keys:
|
|
|
|
coffee: A list of coffeescript fragments that should be compiled and
|
|
placed on the page
|
|
|
|
js: A list of javascript fragments that should be included on the
|
|
page
|
|
|
|
All of these will be loaded onto the page in the CMS
|
|
"""
|
|
# cdodge: We've moved the xmodule.coffee script from an outside directory into the xmodule area of common
|
|
# this means we need to make sure that all xmodules include this dependency which had been previously implicitly
|
|
# fulfilled in a different area of code
|
|
coffee = cls.js.setdefault('coffee', []) # lint-amnesty, pylint: disable=unused-variable
|
|
js = cls.js.setdefault('js', []) # lint-amnesty, pylint: disable=unused-variable
|
|
|
|
# Added xmodule.js separately to enforce 000 prefix for this only.
|
|
cls.js.setdefault('xmodule_js', resource_string(__name__, 'js/src/xmodule.js'))
|
|
|
|
return cls.js
|
|
|
|
@classmethod
|
|
def get_preview_view_js(cls):
|
|
return cls.preview_view_js
|
|
|
|
@classmethod
|
|
def get_preview_view_js_bundle_name(cls):
|
|
return cls.__name__ + 'Preview'
|
|
|
|
@classmethod
|
|
def get_studio_view_js(cls):
|
|
return cls.studio_view_js
|
|
|
|
@classmethod
|
|
def get_studio_view_js_bundle_name(cls):
|
|
return cls.__name__ + 'Studio'
|
|
|
|
@classmethod
|
|
def get_css(cls):
|
|
"""
|
|
Return a dictionary containing some of the following keys:
|
|
|
|
css: A list of css fragments that should be applied to the html
|
|
contents of the snippet
|
|
|
|
sass: A list of sass fragments that should be applied to the html
|
|
contents of the snippet
|
|
|
|
scss: A list of scss fragments that should be applied to the html
|
|
contents of the snippet
|
|
"""
|
|
return cls.css
|
|
|
|
@classmethod
|
|
def get_preview_view_css(cls):
|
|
return cls.preview_view_css
|
|
|
|
@classmethod
|
|
def get_studio_view_css(cls):
|
|
return cls.studio_view_css
|
|
|
|
def get_html(self):
|
|
"""
|
|
Return the html used to display this snippet
|
|
"""
|
|
raise NotImplementedError(
|
|
"get_html() must be provided by specific modules - not present in {}"
|
|
.format(self.__class__))
|
|
|
|
|
|
def shim_xmodule_js(fragment, js_module_name):
|
|
"""
|
|
Set up the XBlock -> XModule shim on the supplied :class:`web_fragments.fragment.Fragment`
|
|
"""
|
|
# Delay this import so that it is only used (and django settings are parsed) when
|
|
# they are required (rather than at startup)
|
|
import webpack_loader.utils # lint-amnesty, pylint: disable=unused-import
|
|
|
|
if not fragment.js_init_fn:
|
|
fragment.initialize_js('XBlockToXModuleShim')
|
|
fragment.json_init_args = {'xmodule-type': js_module_name}
|
|
|
|
add_webpack_to_fragment(fragment, 'XModuleShim')
|
|
|
|
|
|
class XModuleFields:
|
|
"""
|
|
Common fields for XModules.
|
|
"""
|
|
display_name = String(
|
|
display_name=_("Display Name"),
|
|
help=_("The display name for this component."),
|
|
scope=Scope.settings,
|
|
# it'd be nice to have a useful default but it screws up other things; so,
|
|
# use display_name_with_default for those
|
|
default=None
|
|
)
|
|
|
|
|
|
class XModuleMixin(XModuleFields, XBlock):
|
|
"""
|
|
Fields and methods used by XModules internally.
|
|
|
|
Adding this Mixin to an :class:`XBlock` allows it to cooperate with old-style :class:`XModules`
|
|
"""
|
|
# Attributes for inspection of the descriptor
|
|
|
|
# This indicates whether the xmodule is a problem-type.
|
|
# It should respond to max_score() and grade(). It can be graded or ungraded
|
|
# (like a practice problem).
|
|
has_score = False
|
|
|
|
# Whether this module can be displayed in read-only mode. It is safe to set this to True if
|
|
# all user state is handled through the FieldData API.
|
|
show_in_read_only_mode = False
|
|
|
|
# Class level variable
|
|
|
|
# True if this descriptor always requires recalculation of grades, for
|
|
# example if the score can change via an extrnal service, not just when the
|
|
# student interacts with the module on the page. A specific example is
|
|
# FoldIt, which posts grade-changing updates through a separate API.
|
|
always_recalculate_grades = False
|
|
# The default implementation of get_icon_class returns the icon_class
|
|
# attribute of the class
|
|
#
|
|
# This attribute can be overridden by subclasses, and
|
|
# the function can also be overridden if the icon class depends on the data
|
|
# in the module
|
|
icon_class = 'other'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.xmodule_runtime = None
|
|
self._asides = []
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
@property
|
|
def runtime(self):
|
|
return CombinedSystem(self.xmodule_runtime, self._runtime)
|
|
|
|
@runtime.setter
|
|
def runtime(self, value):
|
|
self._runtime = value
|
|
|
|
@property
|
|
def system(self):
|
|
"""
|
|
Return the XBlock runtime (backwards compatibility alias provided for XModules).
|
|
"""
|
|
return self.runtime
|
|
|
|
@property
|
|
def course_id(self):
|
|
return self.location.course_key
|
|
|
|
@property
|
|
def category(self):
|
|
return self.scope_ids.block_type
|
|
|
|
@property
|
|
def location(self):
|
|
return self.scope_ids.usage_id
|
|
|
|
@location.setter
|
|
def location(self, value):
|
|
assert isinstance(value, UsageKey)
|
|
self.scope_ids = self.scope_ids._replace(
|
|
def_id=value,
|
|
usage_id=value,
|
|
)
|
|
|
|
@property
|
|
def url_name(self):
|
|
return block_metadata_utils.url_name_for_block(self)
|
|
|
|
@property
|
|
def display_name_with_default(self):
|
|
"""
|
|
Return a display name for the module: use display_name if defined in
|
|
metadata, otherwise convert the url name.
|
|
"""
|
|
return block_metadata_utils.display_name_with_default(self)
|
|
|
|
@property
|
|
def display_name_with_default_escaped(self):
|
|
"""
|
|
DEPRECATED: use display_name_with_default
|
|
|
|
Return an html escaped display name for the module: use display_name if
|
|
defined in metadata, otherwise convert the url name.
|
|
|
|
Note: This newly introduced method should not be used. It was only
|
|
introduced to enable a quick search/replace and the ability to slowly
|
|
migrate and test switching to display_name_with_default, which is no
|
|
longer escaped.
|
|
"""
|
|
# xss-lint: disable=python-deprecated-display-name
|
|
return block_metadata_utils.display_name_with_default_escaped(self)
|
|
|
|
@property
|
|
def tooltip_title(self):
|
|
"""
|
|
Return the title for the sequence item containing this xmodule as its top level item.
|
|
"""
|
|
return self.display_name_with_default
|
|
|
|
@property
|
|
def xblock_kvs(self):
|
|
"""
|
|
Retrieves the internal KeyValueStore for this XModule.
|
|
|
|
Should only be used by the persistence layer. Use with caution.
|
|
"""
|
|
# if caller wants kvs, caller's assuming it's up to date; so, decache it
|
|
self.save()
|
|
return self._field_data._kvs # pylint: disable=protected-access
|
|
|
|
@lazy
|
|
def _unwrapped_field_data(self):
|
|
"""
|
|
This property hold the value _field_data here before we wrap it in
|
|
the LmsFieldData or OverrideFieldData classes.
|
|
"""
|
|
return self._field_data
|
|
|
|
def add_aside(self, aside):
|
|
"""
|
|
save connected asides
|
|
"""
|
|
self._asides.append(aside)
|
|
|
|
def get_asides(self):
|
|
"""
|
|
get the list of connected asides
|
|
"""
|
|
return self._asides
|
|
|
|
def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
|
|
"""
|
|
Get a dictionary of the fields for the given scope which are set explicitly on this xblock. (Including
|
|
any set to None.)
|
|
"""
|
|
result = {}
|
|
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member
|
|
if field.scope == scope and field.is_set_on(self):
|
|
try:
|
|
result[field.name] = field.read_json(self)
|
|
except TypeError as exception:
|
|
exception_message = "{message}, Block-location:{location}, Field-name:{field_name}".format(
|
|
message=str(exception),
|
|
location=str(self.location),
|
|
field_name=field.name
|
|
)
|
|
raise TypeError(exception_message) # lint-amnesty, pylint: disable=raise-missing-from
|
|
return result
|
|
|
|
def has_children_at_depth(self, depth):
|
|
r"""
|
|
Returns true if self has children at the given depth. depth==0 returns
|
|
false if self is a leaf, true otherwise.
|
|
|
|
SELF
|
|
|
|
|
[child at depth 0]
|
|
/ \
|
|
[depth 1] [depth 1]
|
|
/ \
|
|
[depth 2] [depth 2]
|
|
|
|
So the example above would return True for `has_children_at_depth(2)`, and False
|
|
for depth > 2
|
|
"""
|
|
if depth < 0: # lint-amnesty, pylint: disable=no-else-raise
|
|
raise ValueError("negative depth argument is invalid")
|
|
elif depth == 0:
|
|
return bool(self.get_children())
|
|
else:
|
|
return any(child.has_children_at_depth(depth - 1) for child in self.get_children())
|
|
|
|
def get_content_titles(self):
|
|
r"""
|
|
Returns list of content titles for all of self's children.
|
|
|
|
SEQUENCE
|
|
|
|
|
VERTICAL
|
|
/ \
|
|
SPLIT_TEST DISCUSSION
|
|
/ \
|
|
VIDEO A VIDEO B
|
|
|
|
Essentially, this function returns a list of display_names (e.g. content titles)
|
|
for all of the leaf nodes. In the diagram above, calling get_content_titles on
|
|
SEQUENCE would return the display_names of `VIDEO A`, `VIDEO B`, and `DISCUSSION`.
|
|
|
|
This is most obviously useful for sequence_modules, which need this list to display
|
|
tooltips to users, though in theory this should work for any tree that needs
|
|
the display_names of all its leaf nodes.
|
|
"""
|
|
if self.has_children:
|
|
return sum((child.get_content_titles() for child in self.get_children()), [])
|
|
else:
|
|
# xss-lint: disable=python-deprecated-display-name
|
|
return [self.display_name_with_default_escaped]
|
|
|
|
def get_children(self, usage_id_filter=None, usage_key_filter=None): # pylint: disable=arguments-differ
|
|
"""Returns a list of XBlock instances for the children of
|
|
this module"""
|
|
|
|
# Be backwards compatible with callers using usage_key_filter
|
|
if usage_id_filter is None and usage_key_filter is not None:
|
|
usage_id_filter = usage_key_filter
|
|
|
|
return [
|
|
child
|
|
for child
|
|
in super().get_children(usage_id_filter)
|
|
if child is not None
|
|
]
|
|
|
|
def get_child(self, usage_id):
|
|
"""
|
|
Return the child XBlock identified by ``usage_id``, or ``None`` if there
|
|
is an error while retrieving the block.
|
|
"""
|
|
try:
|
|
child = super().get_child(usage_id)
|
|
except ItemNotFoundError:
|
|
log.warning('Unable to load item %s, skipping', usage_id)
|
|
return None
|
|
|
|
if child is None:
|
|
return None
|
|
|
|
child.runtime.export_fs = self.runtime.export_fs
|
|
return child
|
|
|
|
def get_required_module_descriptors(self):
|
|
"""Returns a list of XModuleDescriptor instances upon which this module depends, but are
|
|
not children of this module"""
|
|
return []
|
|
|
|
def get_display_items(self):
|
|
"""
|
|
Returns a list of descendent module instances that will display
|
|
immediately inside this module.
|
|
"""
|
|
items = []
|
|
for child in self.get_children():
|
|
items.extend(child.displayable_items())
|
|
|
|
return items
|
|
|
|
def displayable_items(self):
|
|
"""
|
|
Returns list of displayable modules contained by this module. If this
|
|
module is visible, should return [self].
|
|
"""
|
|
return [self]
|
|
|
|
def get_child_by(self, selector):
|
|
"""
|
|
Return a child XBlock that matches the specified selector
|
|
"""
|
|
for child in self.get_children():
|
|
if selector(child):
|
|
return child
|
|
return None
|
|
|
|
def get_icon_class(self):
|
|
"""
|
|
Return a css class identifying this module in the context of an icon
|
|
"""
|
|
return self.icon_class
|
|
|
|
def has_dynamic_children(self):
|
|
"""
|
|
Returns True if this descriptor has dynamic children for a given
|
|
student when the module is created.
|
|
|
|
Returns False if the children of this descriptor are the same
|
|
children that the module will return for any student.
|
|
"""
|
|
return False
|
|
|
|
# Functions used in the LMS
|
|
|
|
def get_score(self):
|
|
"""
|
|
Score the student received on the problem, or None if there is no
|
|
score.
|
|
|
|
Returns:
|
|
dictionary
|
|
{'score': integer, from 0 to get_max_score(),
|
|
'total': get_max_score()}
|
|
|
|
NOTE (vshnayder): not sure if this was the intended return value, but
|
|
that's what it's doing now. I suspect that we really want it to just
|
|
return a number. Would need to change (at least) capa to match if we did that.
|
|
"""
|
|
return None
|
|
|
|
def max_score(self):
|
|
""" Maximum score. Two notes:
|
|
|
|
* This is generic; in abstract, a problem could be 3/5 points on one
|
|
randomization, and 5/7 on another
|
|
|
|
* In practice, this is a Very Bad Idea, and (a) will break some code
|
|
in place (although that code should get fixed), and (b) break some
|
|
analytics we plan to put in place.
|
|
"""
|
|
return None
|
|
|
|
def get_progress(self):
|
|
""" Return a progress.Progress object that represents how far the
|
|
student has gone in this module. Must be implemented to get correct
|
|
progress tracking behavior in nesting modules like sequence and
|
|
vertical.
|
|
|
|
If this module has no notion of progress, return None.
|
|
"""
|
|
return None
|
|
|
|
def bind_for_student(self, xmodule_runtime, user_id, wrappers=None):
|
|
"""
|
|
Set up this XBlock to act as an XModule instead of an XModuleDescriptor.
|
|
|
|
Arguments:
|
|
xmodule_runtime (:class:`ModuleSystem'): the runtime to use when accessing student facing methods
|
|
user_id: The user_id to set in scope_ids
|
|
wrappers: These are a list functions that put a wrapper, such as
|
|
LmsFieldData or OverrideFieldData, around the field_data.
|
|
Note that the functions will be applied in the order in
|
|
which they're listed. So [f1, f2] -> f2(f1(field_data))
|
|
"""
|
|
|
|
# Skip rebinding if we're already bound a user, and it's this user.
|
|
if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id:
|
|
if getattr(xmodule_runtime, 'position', None):
|
|
self.position = xmodule_runtime.position # update the position of the tab
|
|
return
|
|
|
|
# If we are switching users mid-request, save the data from the old user.
|
|
self.save()
|
|
|
|
# Update scope_ids to point to the new user.
|
|
self.scope_ids = self.scope_ids._replace(user_id=user_id)
|
|
|
|
# Clear out any cached instantiated children.
|
|
self.clear_child_cache()
|
|
|
|
# Clear out any cached field data scoped to the old user.
|
|
for field in self.fields.values(): # lint-amnesty, pylint: disable=no-member
|
|
if field.scope in (Scope.parent, Scope.children):
|
|
continue
|
|
|
|
if field.scope.user == UserScope.ONE:
|
|
field._del_cached_value(self) # pylint: disable=protected-access
|
|
# not the most elegant way of doing this, but if we're removing
|
|
# a field from the module's field_data_cache, we should also
|
|
# remove it from its _dirty_fields
|
|
if field in self._dirty_fields:
|
|
del self._dirty_fields[field]
|
|
|
|
# Set the new xmodule_runtime and field_data (which are user-specific)
|
|
self.xmodule_runtime = xmodule_runtime
|
|
|
|
if wrappers is None:
|
|
wrappers = []
|
|
|
|
wrapped_field_data = self._unwrapped_field_data
|
|
for wrapper in wrappers:
|
|
wrapped_field_data = wrapper(wrapped_field_data)
|
|
|
|
self._field_data = wrapped_field_data
|
|
|
|
@property
|
|
def non_editable_metadata_fields(self):
|
|
"""
|
|
Return the list of fields that should not be editable in Studio.
|
|
|
|
When overriding, be sure to append to the superclasses' list.
|
|
"""
|
|
# We are not allowing editing of xblock tag and name fields at this time (for any component).
|
|
return [XBlock.tags, XBlock.name]
|
|
|
|
@property
|
|
def editable_metadata_fields(self):
|
|
"""
|
|
Returns the metadata fields to be edited in Studio. These are fields with scope `Scope.settings`.
|
|
|
|
Can be limited by extending `non_editable_metadata_fields`.
|
|
"""
|
|
metadata_fields = {}
|
|
|
|
# Only use the fields from this class, not mixins
|
|
fields = getattr(self, 'unmixed_class', self.__class__).fields
|
|
|
|
for field in fields.values():
|
|
if field in self.non_editable_metadata_fields:
|
|
continue
|
|
if field.scope not in (Scope.settings, Scope.content):
|
|
continue
|
|
|
|
metadata_fields[field.name] = self._create_metadata_editor_info(field)
|
|
|
|
return metadata_fields
|
|
|
|
def _create_metadata_editor_info(self, field):
|
|
"""
|
|
Creates the information needed by the metadata editor for a specific field.
|
|
"""
|
|
def jsonify_value(field, json_choice):
|
|
"""
|
|
Convert field value to JSON, if needed.
|
|
"""
|
|
if isinstance(json_choice, dict):
|
|
new_json_choice = dict(json_choice) # make a copy so below doesn't change the original
|
|
if 'display_name' in json_choice:
|
|
new_json_choice['display_name'] = get_text(json_choice['display_name'])
|
|
if 'value' in json_choice:
|
|
new_json_choice['value'] = field.to_json(json_choice['value'])
|
|
else:
|
|
new_json_choice = field.to_json(json_choice)
|
|
return new_json_choice
|
|
|
|
def get_text(value):
|
|
"""Localize a text value that might be None."""
|
|
if value is None:
|
|
return None
|
|
else:
|
|
return self.runtime.service(self, "i18n").ugettext(value)
|
|
|
|
# gets the 'default_value' and 'explicitly_set' attrs
|
|
metadata_field_editor_info = self.runtime.get_field_provenance(self, field)
|
|
metadata_field_editor_info['field_name'] = field.name
|
|
metadata_field_editor_info['display_name'] = get_text(field.display_name)
|
|
metadata_field_editor_info['help'] = get_text(field.help)
|
|
metadata_field_editor_info['value'] = field.read_json(self)
|
|
|
|
# We support the following editors:
|
|
# 1. A select editor for fields with a list of possible values (includes Booleans).
|
|
# 2. Number editors for integers and floats.
|
|
# 3. A generic string editor for anything else (editing JSON representation of the value).
|
|
editor_type = "Generic"
|
|
values = field.values
|
|
if "values_provider" in field.runtime_options:
|
|
values = field.runtime_options['values_provider'](self)
|
|
if isinstance(values, (tuple, list)) and len(values) > 0:
|
|
editor_type = "Select"
|
|
values = [jsonify_value(field, json_choice) for json_choice in values]
|
|
elif isinstance(field, Integer):
|
|
editor_type = "Integer"
|
|
elif isinstance(field, Float):
|
|
editor_type = "Float"
|
|
elif isinstance(field, List):
|
|
editor_type = "List"
|
|
elif isinstance(field, Dict):
|
|
editor_type = "Dict"
|
|
elif isinstance(field, RelativeTime):
|
|
editor_type = "RelativeTime"
|
|
elif isinstance(field, String) and field.name == "license":
|
|
editor_type = "License"
|
|
metadata_field_editor_info['type'] = editor_type
|
|
metadata_field_editor_info['options'] = [] if values is None else values
|
|
|
|
return metadata_field_editor_info
|
|
|
|
def public_view(self, _context):
|
|
"""
|
|
Default message for blocks that don't implement public_view
|
|
"""
|
|
alert_html = HTML(
|
|
'<div class="page-banner"><div class="alert alert-warning">'
|
|
'<span class="icon icon-alert fa fa fa-warning" aria-hidden="true"></span>'
|
|
'<div class="message-content">{}</div></div></div>'
|
|
)
|
|
|
|
if self.display_name:
|
|
display_text = _(
|
|
'{display_name} is only accessible to enrolled learners. '
|
|
'Sign in or register, and enroll in this course to view it.'
|
|
).format(
|
|
display_name=self.display_name
|
|
)
|
|
else:
|
|
display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE) # lint-amnesty, pylint: disable=translation-of-non-string
|
|
|
|
return Fragment(alert_html.format(display_text))
|
|
|
|
|
|
class XModuleToXBlockMixin:
|
|
"""
|
|
Common code needed by XModule and XBlocks converted from XModules.
|
|
"""
|
|
@property
|
|
def ajax_url(self):
|
|
"""
|
|
Returns the URL for the ajax handler.
|
|
"""
|
|
return self.runtime.handler_url(self, 'xmodule_handler', '', '').rstrip('/?')
|
|
|
|
@XBlock.handler
|
|
def xmodule_handler(self, request, suffix=None):
|
|
"""
|
|
XBlock handler that wraps `handle_ajax`
|
|
"""
|
|
class FileObjForWebobFiles:
|
|
"""
|
|
Turn Webob cgi.FieldStorage uploaded files into pure file objects.
|
|
|
|
Webob represents uploaded files as cgi.FieldStorage objects, which
|
|
have a .file attribute. We wrap the FieldStorage object, delegating
|
|
attribute access to the .file attribute. But the files have no
|
|
name, so we carry the FieldStorage .filename attribute as the .name.
|
|
|
|
"""
|
|
def __init__(self, webob_file):
|
|
self.file = webob_file.file
|
|
self.name = webob_file.filename
|
|
|
|
def __getattr__(self, name):
|
|
return getattr(self.file, name)
|
|
|
|
# WebOb requests have multiple entries for uploaded files. handle_ajax
|
|
# expects a single entry as a list.
|
|
request_post = MultiDict(request.POST)
|
|
for key in set(request.POST.keys()):
|
|
if hasattr(request.POST[key], "file"):
|
|
request_post[key] = list(map(FileObjForWebobFiles, request.POST.getall(key)))
|
|
|
|
response_data = self.handle_ajax(suffix, request_post)
|
|
return Response(response_data, content_type='application/json', charset='UTF-8')
|
|
|
|
|
|
def policy_key(location):
|
|
"""
|
|
Get the key for a location in a policy file. (Since the policy file is
|
|
specific to a course, it doesn't need the full location url).
|
|
"""
|
|
return f'{location.block_type}/{location.block_id}'
|
|
|
|
|
|
Template = namedtuple("Template", "metadata data children")
|
|
|
|
|
|
class ResourceTemplates:
|
|
"""
|
|
Gets the templates associated w/ a containing cls. The cls must have a 'template_dir_name' attribute.
|
|
It finds the templates as directly in this directory under 'templates'.
|
|
"""
|
|
template_packages = [__name__]
|
|
|
|
@classmethod
|
|
def templates(cls):
|
|
"""
|
|
Returns a list of dictionary field: value objects that describe possible templates that can be used
|
|
to seed a module of this type.
|
|
|
|
Expects a class attribute template_dir_name that defines the directory
|
|
inside the 'templates' resource directory to pull templates from
|
|
"""
|
|
templates = []
|
|
dirname = cls.get_template_dir()
|
|
if dirname is not None:
|
|
for pkg in cls.template_packages:
|
|
if not resource_isdir(pkg, dirname):
|
|
continue
|
|
for template_file in resource_listdir(pkg, dirname):
|
|
if not template_file.endswith('.yaml'):
|
|
log.warning("Skipping unknown template file %s", template_file)
|
|
continue
|
|
template_content = resource_string(pkg, os.path.join(dirname, template_file))
|
|
template = yaml.safe_load(template_content)
|
|
template['template_id'] = template_file
|
|
templates.append(template)
|
|
return templates
|
|
|
|
@classmethod
|
|
def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
if getattr(cls, 'template_dir_name', None):
|
|
dirname = os.path.join('templates', cls.template_dir_name) # lint-amnesty, pylint: disable=no-member
|
|
if not resource_isdir(__name__, dirname):
|
|
log.warning("No resource directory {dir} found when loading {cls_name} templates".format(
|
|
dir=dirname,
|
|
cls_name=cls.__name__,
|
|
))
|
|
return None
|
|
else:
|
|
return dirname
|
|
else:
|
|
return None
|
|
|
|
@classmethod
|
|
def get_template(cls, template_id):
|
|
"""
|
|
Get a single template by the given id (which is the file name identifying it w/in the class's
|
|
template_dir_name)
|
|
|
|
"""
|
|
dirname = cls.get_template_dir()
|
|
if dirname is not None:
|
|
path = os.path.join(dirname, template_id)
|
|
for pkg in cls.template_packages:
|
|
if resource_exists(pkg, path):
|
|
template_content = resource_string(pkg, path)
|
|
template = yaml.safe_load(template_content)
|
|
template['template_id'] = template_id
|
|
return template
|
|
|
|
|
|
class ConfigurableFragmentWrapper:
|
|
"""
|
|
Runtime mixin that allows for composition of many `wrap_xblock` wrappers
|
|
"""
|
|
def __init__(self, wrappers=None, wrappers_asides=None, **kwargs):
|
|
"""
|
|
:param wrappers: A list of wrappers, where each wrapper is:
|
|
|
|
def wrapper(block, view, frag, context):
|
|
...
|
|
return wrapped_frag
|
|
"""
|
|
super().__init__(**kwargs)
|
|
if wrappers is not None:
|
|
self.wrappers = wrappers
|
|
else:
|
|
self.wrappers = []
|
|
if wrappers_asides is not None:
|
|
self.wrappers_asides = wrappers_asides
|
|
else:
|
|
self.wrappers_asides = []
|
|
|
|
def wrap_xblock(self, block, view, frag, context):
|
|
"""
|
|
See :func:`Runtime.wrap_child`
|
|
"""
|
|
for wrapper in self.wrappers:
|
|
frag = wrapper(block, view, frag, context)
|
|
|
|
return frag
|
|
|
|
def wrap_aside(self, block, aside, view, frag, context): # pylint: disable=unused-argument
|
|
"""
|
|
See :func:`Runtime.wrap_child`
|
|
"""
|
|
for wrapper in self.wrappers_asides:
|
|
frag = wrapper(aside, view, frag, context)
|
|
|
|
return frag
|
|
|
|
|
|
# This function exists to give applications (LMS/CMS) a place to monkey-patch until
|
|
# we can refactor modulestore to split out the FieldData half of its interface from
|
|
# the Runtime part of its interface. This function mostly matches the
|
|
# Runtime.handler_url interface.
|
|
#
|
|
# The monkey-patching happens in cms/djangoapps/xblock_config/apps.py and lms/djangoapps/lms_xblock/apps.py
|
|
def descriptor_global_handler_url(block, handler_name, suffix='', query='', thirdparty=False):
|
|
"""
|
|
See :meth:`xblock.runtime.Runtime.handler_url`.
|
|
"""
|
|
raise NotImplementedError("Applications must monkey-patch this function before using handler_url for studio_view")
|
|
|
|
|
|
# This function exists to give applications (LMS/CMS) a place to monkey-patch until
|
|
# we can refactor modulestore to split out the FieldData half of its interface from
|
|
# the Runtime part of its interface. This function matches the Runtime.local_resource_url interface
|
|
#
|
|
# The monkey-patching happens in cms/djangoapps/xblock_config/apps.py and lms/djangoapps/lms_xblock/apps.py
|
|
def descriptor_global_local_resource_url(block, uri):
|
|
"""
|
|
See :meth:`xblock.runtime.Runtime.local_resource_url`.
|
|
"""
|
|
raise NotImplementedError("Applications must monkey-patch this function before using local_resource_url for studio_view") # lint-amnesty, pylint: disable=line-too-long
|
|
|
|
|
|
class MetricsMixin:
|
|
"""
|
|
Mixin for adding metric logging for render and handle methods in the DescriptorSystem and ModuleSystem.
|
|
"""
|
|
|
|
def render(self, block, view_name, context=None): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
start_time = time.time()
|
|
try:
|
|
status = "success"
|
|
return super().render(block, view_name, context=context)
|
|
|
|
except:
|
|
status = "failure"
|
|
raise
|
|
|
|
finally:
|
|
end_time = time.time()
|
|
duration = end_time - start_time
|
|
course_id = getattr(self, 'course_id', '')
|
|
tags = [ # lint-amnesty, pylint: disable=unused-variable
|
|
f'view_name:{view_name}',
|
|
'action:render',
|
|
f'action_status:{status}',
|
|
f'course_id:{course_id}',
|
|
f'block_type:{block.scope_ids.block_type}',
|
|
f'block_family:{block.entry_point}',
|
|
]
|
|
log.debug(
|
|
"%.3fs - render %s.%s (%s)",
|
|
duration,
|
|
block.__class__.__name__,
|
|
view_name,
|
|
getattr(block, 'location', ''),
|
|
)
|
|
|
|
def handle(self, block, handler_name, request, suffix=''): # lint-amnesty, pylint: disable=missing-function-docstring
|
|
start_time = time.time()
|
|
try:
|
|
status = "success"
|
|
return super().handle(block, handler_name, request, suffix=suffix)
|
|
|
|
except:
|
|
status = "failure"
|
|
raise
|
|
|
|
finally:
|
|
end_time = time.time()
|
|
duration = end_time - start_time
|
|
course_id = getattr(self, 'course_id', '')
|
|
tags = [ # lint-amnesty, pylint: disable=unused-variable
|
|
f'handler_name:{handler_name}',
|
|
'action:handle',
|
|
f'action_status:{status}',
|
|
f'course_id:{course_id}',
|
|
f'block_type:{block.scope_ids.block_type}',
|
|
f'block_family:{block.entry_point}',
|
|
]
|
|
log.debug(
|
|
"%.3fs - handle %s.%s (%s)",
|
|
duration,
|
|
block.__class__.__name__,
|
|
handler_name,
|
|
getattr(block, 'location', ''),
|
|
)
|
|
|
|
|
|
class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime):
|
|
"""
|
|
Base class for :class:`Runtime`s to be used with :class:`XModuleDescriptor`s
|
|
"""
|
|
def __init__(
|
|
self, load_item, resources_fs, error_tracker, get_policy=None, disabled_xblock_types=lambda: [], **kwargs
|
|
):
|
|
"""
|
|
load_item: Takes a Location and returns an XModuleDescriptor
|
|
|
|
resources_fs: A Filesystem object that contains all of the
|
|
resources needed for the course
|
|
|
|
error_tracker: A hook for tracking errors in loading the descriptor.
|
|
Used for example to get a list of all non-fatal problems on course
|
|
load, and display them to the user.
|
|
|
|
See errortracker.py for more documentation
|
|
|
|
get_policy: a function that takes a usage id and returns a dict of
|
|
policy to apply.
|
|
|
|
local_resource_url: an implementation of :meth:`xblock.runtime.Runtime.local_resource_url`
|
|
|
|
"""
|
|
kwargs.setdefault('id_reader', OpaqueKeyReader())
|
|
kwargs.setdefault('id_generator', AsideKeyGenerator())
|
|
super().__init__(**kwargs)
|
|
|
|
# This is used by XModules to write out separate files during xml export
|
|
self.export_fs = None
|
|
|
|
self.load_item = load_item
|
|
self.resources_fs = resources_fs
|
|
self.error_tracker = error_tracker
|
|
if get_policy:
|
|
self.get_policy = get_policy
|
|
else:
|
|
self.get_policy = lambda u: {}
|
|
|
|
self.disabled_xblock_types = disabled_xblock_types
|
|
|
|
def get_block(self, usage_id, for_parent=None):
|
|
"""See documentation for `xblock.runtime:Runtime.get_block`"""
|
|
return self.load_item(usage_id, for_parent=for_parent)
|
|
|
|
def load_block_type(self, block_type):
|
|
"""
|
|
Returns a subclass of :class:`.XBlock` that corresponds to the specified `block_type`.
|
|
"""
|
|
if block_type in self.disabled_xblock_types():
|
|
return self.default_class
|
|
return super().load_block_type(block_type)
|
|
|
|
def get_field_provenance(self, xblock, field):
|
|
"""
|
|
For the given xblock, return a dict for the field's current state:
|
|
{
|
|
'default_value': what json'd value will take effect if field is unset: either the field default or
|
|
inherited value,
|
|
'explicitly_set': boolean for whether the current value is set v default/inherited,
|
|
}
|
|
:param xblock:
|
|
:param field:
|
|
"""
|
|
# pylint: disable=protected-access
|
|
# in runtime b/c runtime contains app-specific xblock behavior. Studio's the only app
|
|
# which needs this level of introspection right now. runtime also is 'allowed' to know
|
|
# about the kvs, dbmodel, etc.
|
|
|
|
result = {}
|
|
result['explicitly_set'] = xblock._field_data.has(xblock, field.name)
|
|
try:
|
|
result['default_value'] = xblock._field_data.default(xblock, field.name)
|
|
except KeyError:
|
|
result['default_value'] = field.to_json(field.default)
|
|
return result
|
|
|
|
def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False):
|
|
# Currently, Modulestore is responsible for instantiating DescriptorSystems
|
|
# This means that LMS/CMS don't have a way to define a subclass of DescriptorSystem
|
|
# that implements the correct handler url. So, for now, instead, we will reference a
|
|
# global function that the application can override.
|
|
return descriptor_global_handler_url(block, handler_name, suffix, query, thirdparty)
|
|
|
|
def local_resource_url(self, block, uri):
|
|
"""
|
|
See :meth:`xblock.runtime.Runtime:local_resource_url` for documentation.
|
|
"""
|
|
# Currently, Modulestore is responsible for instantiating DescriptorSystems
|
|
# This means that LMS/CMS don't have a way to define a subclass of DescriptorSystem
|
|
# that implements the correct local_resource_url. So, for now, instead, we will reference a
|
|
# global function that the application can override.
|
|
return descriptor_global_local_resource_url(block, uri)
|
|
|
|
def applicable_aside_types(self, block):
|
|
"""
|
|
See :meth:`xblock.runtime.Runtime:applicable_aside_types` for documentation.
|
|
"""
|
|
potential_set = set(super().applicable_aside_types(block))
|
|
if getattr(block, 'xmodule_runtime', None) is not None:
|
|
if hasattr(block.xmodule_runtime, 'applicable_aside_types'):
|
|
application_set = set(block.xmodule_runtime.applicable_aside_types(block))
|
|
return list(potential_set.intersection(application_set))
|
|
return list(potential_set)
|
|
|
|
def resource_url(self, resource):
|
|
"""
|
|
See :meth:`xblock.runtime.Runtime:resource_url` for documentation.
|
|
"""
|
|
raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls")
|
|
|
|
def add_block_as_child_node(self, block, node):
|
|
child = etree.SubElement(node, "unknown")
|
|
child.set('url_name', block.url_name)
|
|
block.add_xml_to_node(child)
|
|
|
|
def publish(self, block, event_type, event): # lint-amnesty, pylint: disable=arguments-differ
|
|
# A stub publish method that doesn't emit any events from XModuleDescriptors.
|
|
pass
|
|
|
|
def service(self, block, service_name):
|
|
"""
|
|
Runtime-specific override for the XBlock service manager. If a service is not currently
|
|
instantiated and is declared as a critical requirement, an attempt is made to load the
|
|
module.
|
|
|
|
Arguments:
|
|
block (an XBlock): this block's class will be examined for service
|
|
decorators.
|
|
service_name (string): the name of the service requested.
|
|
|
|
Returns:
|
|
An object implementing the requested service, or None.
|
|
"""
|
|
# getting the service from parent module. making sure of block service declarations.
|
|
service = super().service(block=block, service_name=service_name)
|
|
# Passing the block to service if it is callable e.g. ModuleI18nService. It is the responsibility of calling
|
|
# service to handle the passing argument.
|
|
if callable(service):
|
|
return service(block)
|
|
return service
|
|
|
|
|
|
class XMLParsingSystem(DescriptorSystem): # lint-amnesty, pylint: disable=abstract-method, missing-class-docstring
|
|
def __init__(self, process_xml, **kwargs):
|
|
"""
|
|
process_xml: Takes an xml string, and returns a XModuleDescriptor
|
|
created from that xml
|
|
"""
|
|
|
|
super().__init__(**kwargs)
|
|
self.process_xml = process_xml
|
|
|
|
def _usage_id_from_node(self, node, parent_id, id_generator=None):
|
|
"""Create a new usage id from an XML dom node.
|
|
|
|
Args:
|
|
node (lxml.etree.Element): The DOM node to interpret.
|
|
parent_id: The usage ID of the parent block
|
|
id_generator (IdGenerator): The :class:`.IdGenerator` to use
|
|
for creating ids
|
|
Returns:
|
|
UsageKey: the usage key for the new xblock
|
|
"""
|
|
return self.xblock_from_node(node, parent_id, id_generator).scope_ids.usage_id
|
|
|
|
def xblock_from_node(self, node, parent_id, id_generator=None):
|
|
"""
|
|
Create an XBlock instance from XML data.
|
|
|
|
Args:
|
|
xml_data (string): A string containing valid xml.
|
|
system (XMLParsingSystem): The :class:`.XMLParsingSystem` used to connect the block
|
|
to the outside world.
|
|
id_generator (IdGenerator): An :class:`~xblock.runtime.IdGenerator` that
|
|
will be used to construct the usage_id and definition_id for the block.
|
|
|
|
Returns:
|
|
XBlock: The fully instantiated :class:`~xblock.core.XBlock`.
|
|
|
|
"""
|
|
id_generator = id_generator or self.id_generator
|
|
# leave next line commented out - useful for low-level debugging
|
|
# log.debug('[_usage_id_from_node] tag=%s, class=%s' % (node.tag, xblock_class))
|
|
|
|
block_type = node.tag
|
|
# remove xblock-family from elements
|
|
node.attrib.pop('xblock-family', None)
|
|
|
|
url_name = node.get('url_name') # difference from XBlock.runtime
|
|
def_id = id_generator.create_definition(block_type, url_name)
|
|
usage_id = id_generator.create_usage(def_id)
|
|
|
|
keys = ScopeIds(None, block_type, def_id, usage_id)
|
|
block_class = self.mixologist.mix(self.load_block_type(block_type))
|
|
|
|
aside_children = self.parse_asides(node, def_id, usage_id, id_generator)
|
|
asides_tags = [x.tag for x in aside_children]
|
|
|
|
block = block_class.parse_xml(node, self, keys, id_generator)
|
|
self._convert_reference_fields_to_keys(block) # difference from XBlock.runtime
|
|
block.parent = parent_id
|
|
block.save()
|
|
|
|
asides = self.get_asides(block)
|
|
for asd in asides:
|
|
if asd.scope_ids.block_type in asides_tags:
|
|
block.add_aside(asd)
|
|
|
|
return block
|
|
|
|
def parse_asides(self, node, def_id, usage_id, id_generator):
|
|
"""pull the asides out of the xml payload and instantiate them"""
|
|
aside_children = []
|
|
for child in node.iterchildren():
|
|
# get xblock-family from node
|
|
xblock_family = child.attrib.pop('xblock-family', None)
|
|
if xblock_family:
|
|
xblock_family = self._family_id_to_superclass(xblock_family)
|
|
if issubclass(xblock_family, XBlockAside):
|
|
aside_children.append(child)
|
|
# now process them & remove them from the xml payload
|
|
for child in aside_children:
|
|
self._aside_from_xml(child, def_id, usage_id, id_generator)
|
|
node.remove(child)
|
|
return aside_children
|
|
|
|
def _make_usage_key(self, course_key, value):
|
|
"""
|
|
Makes value into a UsageKey inside the specified course.
|
|
If value is already a UsageKey, returns that.
|
|
"""
|
|
if isinstance(value, UsageKey):
|
|
return value
|
|
usage_key = UsageKey.from_string(value)
|
|
return usage_key.map_into_course(course_key)
|
|
|
|
def _convert_reference_fields_to_keys(self, xblock):
|
|
"""
|
|
Find all fields of type reference and convert the payload into UsageKeys
|
|
"""
|
|
course_key = xblock.scope_ids.usage_id.course_key
|
|
|
|
for field in xblock.fields.values():
|
|
if field.is_set_on(xblock):
|
|
field_value = getattr(xblock, field.name)
|
|
if field_value is None:
|
|
continue
|
|
elif isinstance(field, Reference):
|
|
setattr(xblock, field.name, self._make_usage_key(course_key, field_value))
|
|
elif isinstance(field, ReferenceList):
|
|
setattr(xblock, field.name, [self._make_usage_key(course_key, ele) for ele in field_value])
|
|
elif isinstance(field, ReferenceValueDict):
|
|
for key, subvalue in field_value.items():
|
|
assert isinstance(subvalue, str)
|
|
field_value[key] = self._make_usage_key(course_key, subvalue)
|
|
setattr(xblock, field.name, field_value)
|
|
|
|
|
|
class ModuleSystemShim:
|
|
"""
|
|
This shim provides the properties formerly available from ModuleSystem which are now being provided by services.
|
|
|
|
This shim will be removed, so all properties raise a deprecation warning.
|
|
"""
|
|
|
|
@property
|
|
def anonymous_student_id(self):
|
|
"""
|
|
Returns the anonymous user ID for the current user and course.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.anonymous_student_id is deprecated. Please use the user service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
user_service = self._services.get('user')
|
|
if user_service:
|
|
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID)
|
|
return None
|
|
|
|
@property
|
|
def seed(self):
|
|
"""
|
|
Returns the numeric current user id, for use as a random seed.
|
|
Returns 0 if there is no current user.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.seed is deprecated. Please use the user service `user_id` instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
return self.user_id or 0
|
|
|
|
@property
|
|
def user_id(self):
|
|
"""
|
|
Returns the current user id, or None if there is no current user.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.user_id is deprecated. Please use the user service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
user_service = self._services.get('user')
|
|
if user_service:
|
|
return user_service.get_current_user().opt_attrs.get(ATTR_KEY_USER_ID)
|
|
return None
|
|
|
|
@property
|
|
def user_is_staff(self):
|
|
"""
|
|
Returns whether the current user has staff access to the course.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.user_is_staff is deprecated. Please use the user service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
user_service = self._services.get('user')
|
|
if user_service:
|
|
return self._services['user'].get_current_user().opt_attrs.get(ATTR_KEY_USER_IS_STAFF)
|
|
return None
|
|
|
|
@property
|
|
def user_location(self):
|
|
"""
|
|
Returns the "country code" associated with the current user's request IP address.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.user_location is deprecated. Please use the user service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
user_service = self._services.get('user')
|
|
if user_service:
|
|
return self._services['user'].get_current_user().opt_attrs.get(ATTR_KEY_REQUEST_COUNTRY_CODE)
|
|
return None
|
|
|
|
@property
|
|
def get_real_user(self):
|
|
"""
|
|
Returns a function that takes `anonymous_student_id` and returns the Django User object
|
|
associated with `anonymous_student_id`.
|
|
|
|
If no `anonymous_student_id` is provided as an argument to this function, then the user service's anonymous user
|
|
ID is used instead.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.get_real_user is deprecated. Please use the user service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
user_service = self._services.get('user')
|
|
if user_service:
|
|
return self._services['user'].get_user_by_anonymous_id
|
|
return None
|
|
|
|
@property
|
|
def get_user_role(self):
|
|
"""
|
|
Returns a function that returns the user's role in the course.
|
|
|
|
Implementation is different for LMS and Studio.
|
|
|
|
Deprecated in favor of the user service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.get_user_role is deprecated. Please use the user service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
user_service = self._services.get('user')
|
|
if user_service:
|
|
return partial(self._services['user'].get_current_user().opt_attrs.get, ATTR_KEY_USER_ROLE)
|
|
|
|
@property
|
|
def render_template(self):
|
|
"""
|
|
Returns a function that takes (template_file, context), and returns rendered html.
|
|
|
|
Deprecated in favor of the mako service.
|
|
"""
|
|
warnings.warn(
|
|
'Use of runtime.render_template is deprecated. '
|
|
'Use MakoService.render_template or a JavaScript-based template instead.',
|
|
DeprecationWarning, stacklevel=2,
|
|
)
|
|
render_service = self._services.get('mako')
|
|
if render_service:
|
|
return render_service.render_template
|
|
return None
|
|
|
|
@property
|
|
def xqueue(self):
|
|
"""
|
|
Returns a dict containing the XQueueInterface object, as well as parameters for the specific StudentModule:
|
|
* interface: XQueueInterface object
|
|
* construct_callback: function to construct the fully-qualified LMS callback URL.
|
|
* default_queuename: default queue name for the course in XQueue
|
|
* waittime: number of seconds to wait in between calls to XQueue
|
|
|
|
Deprecated in favor of the xqueue service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.xqueue is deprecated. Please use the xqueue service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
xqueue_service = self._services.get('xqueue')
|
|
if xqueue_service:
|
|
return {
|
|
'interface': xqueue_service.interface,
|
|
'construct_callback': xqueue_service.construct_callback,
|
|
'default_queuename': xqueue_service.default_queuename,
|
|
'waittime': xqueue_service.waittime,
|
|
}
|
|
return None
|
|
|
|
@property
|
|
def can_execute_unsafe_code(self):
|
|
"""
|
|
Returns a function which returns a boolean, indicating whether or not to allow the execution of unsafe,
|
|
unsandboxed code.
|
|
|
|
Deprecated in favor of the sandbox service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.can_execute_unsafe_code is deprecated. Please use the sandbox service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
sandbox_service = self._services.get('sandbox')
|
|
if sandbox_service:
|
|
return sandbox_service.can_execute_unsafe_code
|
|
# Default to saying "no unsafe code".
|
|
return lambda: False
|
|
|
|
@property
|
|
def get_python_lib_zip(self):
|
|
"""
|
|
Returns a function returning a bytestring or None.
|
|
|
|
The bytestring is the contents of a zip file that should be importable by other Python code running in the
|
|
module.
|
|
|
|
Deprecated in favor of the sandbox service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.get_python_lib_zip is deprecated. Please use the sandbox service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
sandbox_service = self._services.get('sandbox')
|
|
if sandbox_service:
|
|
return sandbox_service.get_python_lib_zip
|
|
# Default to saying "no lib data"
|
|
return lambda: None
|
|
|
|
@property
|
|
def cache(self):
|
|
"""
|
|
Returns a cache object with two methods:
|
|
* .get(key) returns an object from the cache or None.
|
|
* .set(key, value, timeout_secs=None) stores a value in the cache with a timeout.
|
|
|
|
Deprecated in favor of the cache service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.cache is deprecated. Please use the cache service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
return self._services.get('cache') or DoNothingCache()
|
|
|
|
@property
|
|
def replace_urls(self):
|
|
"""
|
|
Returns a function to replace static urls with course specific urls.
|
|
|
|
Deprecated in favor of the replace_urls service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.replace_urls is deprecated. Please use the replace_urls service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
replace_urls_service = self._services.get('replace_urls')
|
|
if replace_urls_service:
|
|
return partial(replace_urls_service.replace_urls, static_replace_only=True)
|
|
|
|
@property
|
|
def replace_course_urls(self):
|
|
"""
|
|
Returns a function to replace static urls with course specific urls.
|
|
|
|
Deprecated in favor of the replace_urls service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.replace_course_urls is deprecated. Please use the replace_urls service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
replace_urls_service = self._services.get('replace_urls')
|
|
if replace_urls_service:
|
|
return partial(replace_urls_service.replace_urls)
|
|
|
|
@property
|
|
def replace_jump_to_id_urls(self):
|
|
"""
|
|
Returns a function to replace static urls with course specific urls.
|
|
|
|
Deprecated in favor of the replace_urls service.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.replace_jump_to_id_urls is deprecated. Please use the replace_urls service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
replace_urls_service = self._services.get('replace_urls')
|
|
if replace_urls_service:
|
|
return partial(replace_urls_service.replace_urls)
|
|
|
|
@property
|
|
def filestore(self):
|
|
"""
|
|
A filestore ojbect. Defaults to an instance of OSFS based at settings.DATA_DIR.
|
|
|
|
Deprecated in favor of runtime.resources_fs property.
|
|
"""
|
|
warnings.warn(
|
|
'runtime.filestore is deprecated. Please use the runtime.resources_fs service instead.',
|
|
DeprecationWarning, stacklevel=3,
|
|
)
|
|
return self.resources_fs
|
|
|
|
@property
|
|
def node_path(self):
|
|
"""
|
|
Path to node_modules. Doesn't seem to be used by any ModuleSystem dependent core XBlock anymore.
|
|
|
|
Deprecated.
|
|
"""
|
|
warnings.warn(
|
|
'node_path is deprecated. Please use other methods of finding the node_modules location.',
|
|
DeprecationWarning, stacklevel=3
|
|
)
|
|
|
|
|
|
class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, ModuleSystemShim, Runtime):
|
|
"""
|
|
This is an abstraction such that x_modules can function independent
|
|
of the courseware (e.g. import into other types of courseware, LMS,
|
|
or if we want to have a sandbox server for user-contributed content)
|
|
|
|
ModuleSystem objects are passed to x_modules to provide access to system
|
|
functionality.
|
|
|
|
Note that these functions can be closures over e.g. a django request
|
|
and user, or other environment-specific info.
|
|
"""
|
|
|
|
def __init__(
|
|
self, static_url, track_function, get_module,
|
|
descriptor_runtime, debug=False, hostname="", publish=None,
|
|
course_id=None, error_descriptor_class=None,
|
|
field_data=None, rebind_noauth_module_to_user=None,
|
|
**kwargs):
|
|
"""
|
|
Create a closure around the system environment.
|
|
|
|
static_url - the base URL to static assets
|
|
|
|
track_function - function of (event_type, event), intended for logging
|
|
or otherwise tracking the event.
|
|
TODO: Not used, and has inconsistent args in different
|
|
files. Update or remove.
|
|
|
|
get_module - function that takes a descriptor and returns a corresponding
|
|
module instance object. If the current user does not have
|
|
access to that location, returns None.
|
|
|
|
descriptor_runtime - A `DescriptorSystem` to use for loading xblocks by id
|
|
|
|
course_id - the course_id containing this module
|
|
|
|
publish(event) - A function that allows XModules to publish events (such as grade changes)
|
|
|
|
error_descriptor_class - The class to use to render XModules with errors
|
|
|
|
field_data - the `FieldData` to use for backing XBlock storage.
|
|
|
|
rebind_noauth_module_to_user - rebinds module bound to AnonymousUser to a real user...used in LTI
|
|
modules, which have an anonymous handler, to set legitimate users' data
|
|
"""
|
|
|
|
# Usage_store is unused, and field_data is often supplanted with an
|
|
# explicit field_data during construct_xblock.
|
|
kwargs.setdefault('id_reader', getattr(descriptor_runtime, 'id_reader', OpaqueKeyReader()))
|
|
kwargs.setdefault('id_generator', getattr(descriptor_runtime, 'id_generator', AsideKeyGenerator()))
|
|
super().__init__(field_data=field_data, **kwargs)
|
|
|
|
self.STATIC_URL = static_url
|
|
self.track_function = track_function
|
|
self.get_module = get_module
|
|
self.DEBUG = self.debug = debug
|
|
self.HOSTNAME = self.hostname = hostname
|
|
self.course_id = course_id
|
|
|
|
if publish:
|
|
self.publish = publish
|
|
self.error_descriptor_class = error_descriptor_class
|
|
self.xmodule_instance = None
|
|
|
|
self.descriptor_runtime = descriptor_runtime
|
|
self.rebind_noauth_module_to_user = rebind_noauth_module_to_user
|
|
|
|
def get(self, attr):
|
|
""" provide uniform access to attributes (like etree)."""
|
|
return self.__dict__.get(attr)
|
|
|
|
def set(self, attr, val):
|
|
"""provide uniform access to attributes (like etree)"""
|
|
self.__dict__[attr] = val
|
|
|
|
def __repr__(self):
|
|
kwargs = self.__dict__.copy()
|
|
|
|
# Remove value set transiently by XBlock
|
|
kwargs.pop('_view_name')
|
|
|
|
return f"{self.__class__.__name__}{kwargs}"
|
|
|
|
@property
|
|
def ajax_url(self):
|
|
"""
|
|
The url prefix to be used by XModules to call into handle_ajax
|
|
"""
|
|
assert self.xmodule_instance is not None
|
|
return self.handler_url(self.xmodule_instance, 'xmodule_handler', '', '').rstrip('/?')
|
|
|
|
def get_block(self, block_id, for_parent=None): # lint-amnesty, pylint: disable=arguments-differ
|
|
return self.get_module(self.descriptor_runtime.get_block(block_id, for_parent=for_parent))
|
|
|
|
def resource_url(self, resource):
|
|
raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls")
|
|
|
|
def publish(self, block, event_type, event): # lint-amnesty, pylint: disable=arguments-differ
|
|
pass
|
|
|
|
def service(self, block, service_name):
|
|
"""
|
|
Runtime-specific override for the XBlock service manager. If a service is not currently
|
|
instantiated and is declared as a critical requirement, an attempt is made to load the
|
|
module.
|
|
|
|
Arguments:
|
|
block (an XBlock): this block's class will be examined for service
|
|
decorators.
|
|
service_name (string): the name of the service requested.
|
|
|
|
Returns:
|
|
An object implementing the requested service, or None.
|
|
"""
|
|
# getting the service from parent module. making sure of block service declarations.
|
|
service = super().service(block=block, service_name=service_name)
|
|
# Passing the block to service if it is callable e.g. ModuleI18nService. It is the responsibility of calling
|
|
# service to handle the passing argument.
|
|
if callable(service):
|
|
return service(block)
|
|
return service
|
|
|
|
|
|
class CombinedSystem:
|
|
"""
|
|
This class is a shim to allow both pure XBlocks and XModuleDescriptors
|
|
that have been bound as XModules to access both the attributes of ModuleSystem
|
|
and of DescriptorSystem as a single runtime.
|
|
"""
|
|
|
|
__slots__ = ('_module_system', '_descriptor_system')
|
|
|
|
# This system doesn't override a number of methods that are provided by ModuleSystem and DescriptorSystem,
|
|
# namely handler_url, local_resource_url, query, and resource_url.
|
|
#
|
|
# At runtime, the ModuleSystem and/or DescriptorSystem will define those methods
|
|
#
|
|
def __init__(self, module_system, descriptor_system):
|
|
# These attributes are set directly to __dict__ below to avoid a recursion in getattr/setattr.
|
|
self._module_system = module_system
|
|
self._descriptor_system = descriptor_system
|
|
|
|
def render(self, block, view_name, context=None):
|
|
"""
|
|
Render a block by invoking its view.
|
|
|
|
Finds the view named `view_name` on `block`. The default view will be
|
|
used if a specific view hasn't be registered. If there is no default
|
|
view, an exception will be raised.
|
|
|
|
The view is invoked, passing it `context`. The value returned by the
|
|
view is returned, with possible modifications by the runtime to
|
|
integrate it into a larger whole.
|
|
|
|
"""
|
|
context = context or {}
|
|
return self.__getattr__('render')(block, view_name, context)
|
|
|
|
def service(self, block, service_name):
|
|
"""Return a service, or None.
|
|
|
|
Services are objects implementing arbitrary other interfaces. They are
|
|
requested by agreed-upon names, see [XXX TODO] for a list of possible
|
|
services. The object returned depends on the service requested.
|
|
|
|
XBlocks must announce their intention to request services with the
|
|
`XBlock.needs` or `XBlock.wants` decorators. Use `needs` if you assume
|
|
that the service is available, or `wants` if your code is flexible and
|
|
can accept a None from this method.
|
|
|
|
Runtimes can override this method if they have different techniques for
|
|
finding and delivering services.
|
|
|
|
Arguments:
|
|
block (an XBlock): this block's class will be examined for service
|
|
decorators.
|
|
service_name (string): the name of the service requested.
|
|
|
|
Returns:
|
|
An object implementing the requested service, or None.
|
|
|
|
"""
|
|
service = None
|
|
|
|
if self._module_system:
|
|
service = self._module_system.service(block, service_name)
|
|
|
|
if service is None:
|
|
service = self._descriptor_system.service(block, service_name)
|
|
|
|
return service
|
|
|
|
def __getattr__(self, name):
|
|
"""
|
|
If the ModuleSystem doesn't have an attribute, try returning the same attribute from the
|
|
DescriptorSystem, instead. This allows XModuleDescriptors that are bound as XModules
|
|
to still function as XModuleDescriptors.
|
|
"""
|
|
# First we try a lookup in the module system...
|
|
try:
|
|
return getattr(self._module_system, name)
|
|
except AttributeError:
|
|
return getattr(self._descriptor_system, name)
|
|
|
|
def __setattr__(self, name, value):
|
|
"""
|
|
If the ModuleSystem is set, set the attr on it.
|
|
Always set the attr on the DescriptorSystem.
|
|
"""
|
|
if name in self.__slots__:
|
|
return super().__setattr__(name, value)
|
|
|
|
if self._module_system:
|
|
setattr(self._module_system, name, value)
|
|
setattr(self._descriptor_system, name, value)
|
|
|
|
def __delattr__(self, name):
|
|
"""
|
|
If the ModuleSystem is set, delete the attribute from it.
|
|
Always delete the attribute from the DescriptorSystem.
|
|
"""
|
|
if self._module_system:
|
|
delattr(self._module_system, name)
|
|
delattr(self._descriptor_system, name)
|
|
|
|
def __repr__(self):
|
|
return f"CombinedSystem({self._module_system!r}, {self._descriptor_system!r})"
|
|
|
|
|
|
class DoNothingCache:
|
|
"""A duck-compatible object to use in ModuleSystem when there's no cache."""
|
|
def get(self, _key):
|
|
return None
|
|
|
|
def set(self, key, value, timeout=None):
|
|
pass
|