Override field data within the XBlock runtime
Resolves an issue preventing students in self-paced courses from seeing all available discussion modules. ECOM-3733.
This commit is contained in:
@@ -283,6 +283,17 @@ else:
|
|||||||
DATABASES = AUTH_TOKENS['DATABASES']
|
DATABASES = AUTH_TOKENS['DATABASES']
|
||||||
|
|
||||||
MODULESTORE = convert_module_store_setting_if_needed(AUTH_TOKENS.get('MODULESTORE', MODULESTORE))
|
MODULESTORE = convert_module_store_setting_if_needed(AUTH_TOKENS.get('MODULESTORE', MODULESTORE))
|
||||||
|
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ENV_TOKENS.get(
|
||||||
|
'MODULESTORE_FIELD_OVERRIDE_PROVIDERS',
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS
|
||||||
|
)
|
||||||
|
|
||||||
|
XBLOCK_FIELD_DATA_WRAPPERS = ENV_TOKENS.get(
|
||||||
|
'XBLOCK_FIELD_DATA_WRAPPERS',
|
||||||
|
XBLOCK_FIELD_DATA_WRAPPERS
|
||||||
|
)
|
||||||
|
|
||||||
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
|
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
|
||||||
DOC_STORE_CONFIG = AUTH_TOKENS['DOC_STORE_CONFIG']
|
DOC_STORE_CONFIG = AUTH_TOKENS['DOC_STORE_CONFIG']
|
||||||
# Datadog for events!
|
# Datadog for events!
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ XBLOCK_MIXINS = (
|
|||||||
|
|
||||||
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||||
|
|
||||||
|
# Paths to wrapper methods which should be applied to every XBlock's FieldData.
|
||||||
|
XBLOCK_FIELD_DATA_WRAPPERS = ()
|
||||||
|
|
||||||
############################ Modulestore Configuration ################################
|
############################ Modulestore Configuration ################################
|
||||||
MODULESTORE_BRANCH = 'draft-preferred'
|
MODULESTORE_BRANCH = 'draft-preferred'
|
||||||
|
|
||||||
@@ -417,6 +420,10 @@ MODULESTORE = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Modulestore-level field override providers. These field override providers don't
|
||||||
|
# require student context.
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ()
|
||||||
|
|
||||||
#################### Python sandbox ############################################
|
#################### Python sandbox ############################################
|
||||||
|
|
||||||
CODE_JAIL = {
|
CODE_JAIL = {
|
||||||
|
|||||||
@@ -1160,7 +1160,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
|
|||||||
contentstore=None,
|
contentstore=None,
|
||||||
doc_store_config=None, # ignore if passed up
|
doc_store_config=None, # ignore if passed up
|
||||||
metadata_inheritance_cache_subsystem=None, request_cache=None,
|
metadata_inheritance_cache_subsystem=None, request_cache=None,
|
||||||
xblock_mixins=(), xblock_select=None, disabled_xblock_types=(), # pylint: disable=bad-continuation
|
xblock_mixins=(), xblock_select=None, xblock_field_data_wrappers=(), disabled_xblock_types=(), # pylint: disable=bad-continuation
|
||||||
# temporary parms to enable backward compatibility. remove once all envs migrated
|
# temporary parms to enable backward compatibility. remove once all envs migrated
|
||||||
db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None,
|
db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None,
|
||||||
# allow lower level init args to pass harmlessly
|
# allow lower level init args to pass harmlessly
|
||||||
@@ -1177,6 +1177,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
|
|||||||
self.request_cache = request_cache
|
self.request_cache = request_cache
|
||||||
self.xblock_mixins = xblock_mixins
|
self.xblock_mixins = xblock_mixins
|
||||||
self.xblock_select = xblock_select
|
self.xblock_select = xblock_select
|
||||||
|
self.xblock_field_data_wrappers = xblock_field_data_wrappers
|
||||||
self.disabled_xblock_types = disabled_xblock_types
|
self.disabled_xblock_types = disabled_xblock_types
|
||||||
self.contentstore = contentstore
|
self.contentstore = contentstore
|
||||||
|
|
||||||
|
|||||||
@@ -120,11 +120,25 @@ def load_function(path):
|
|||||||
"""
|
"""
|
||||||
Load a function by name.
|
Load a function by name.
|
||||||
|
|
||||||
path is a string of the form "path.to.module.function"
|
Arguments:
|
||||||
returns the imported python object `function` from `path.to.module`
|
path: String of the form 'path.to.module.function'. Strings of the form
|
||||||
|
'path.to.module:Class.function' are also valid.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The imported object 'function'.
|
||||||
"""
|
"""
|
||||||
module_path, _, name = path.rpartition('.')
|
if ':' in path:
|
||||||
return getattr(import_module(module_path), name)
|
module_path, _, method_path = path.rpartition(':')
|
||||||
|
module = import_module(module_path)
|
||||||
|
|
||||||
|
class_name, method_name = method_path.split('.')
|
||||||
|
_class = getattr(module, class_name)
|
||||||
|
function = getattr(_class, method_name)
|
||||||
|
else:
|
||||||
|
module_path, _, name = path.rpartition('.')
|
||||||
|
function = getattr(import_module(module_path), name)
|
||||||
|
|
||||||
|
return function
|
||||||
|
|
||||||
|
|
||||||
def create_modulestore_instance(
|
def create_modulestore_instance(
|
||||||
@@ -179,12 +193,15 @@ def create_modulestore_instance(
|
|||||||
else:
|
else:
|
||||||
disabled_xblock_types = ()
|
disabled_xblock_types = ()
|
||||||
|
|
||||||
|
xblock_field_data_wrappers = [load_function(path) for path in settings.XBLOCK_FIELD_DATA_WRAPPERS]
|
||||||
|
|
||||||
return class_(
|
return class_(
|
||||||
contentstore=content_store,
|
contentstore=content_store,
|
||||||
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
|
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
|
||||||
request_cache=request_cache,
|
request_cache=request_cache,
|
||||||
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
|
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
|
||||||
xblock_select=getattr(settings, 'XBLOCK_SELECT_FUNCTION', None),
|
xblock_select=getattr(settings, 'XBLOCK_SELECT_FUNCTION', None),
|
||||||
|
xblock_field_data_wrappers=xblock_field_data_wrappers,
|
||||||
disabled_xblock_types=disabled_xblock_types,
|
disabled_xblock_types=disabled_xblock_types,
|
||||||
doc_store_config=doc_store_config,
|
doc_store_config=doc_store_config,
|
||||||
i18n_service=i18n_service or ModuleI18nService(),
|
i18n_service=i18n_service or ModuleI18nService(),
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ class InheritanceMixin(XBlockMixin):
|
|||||||
default=False
|
default=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self_paced = Boolean(
|
||||||
|
display_name=_('Self Paced'),
|
||||||
|
help=_(
|
||||||
|
'Set this to "true" to mark this course as self-paced. Self-paced courses do not have '
|
||||||
|
'due dates for assignments, and students can progress through the course at any rate before '
|
||||||
|
'the course ends.'
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
scope=Scope.settings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def compute_inherited_metadata(descriptor):
|
def compute_inherited_metadata(descriptor):
|
||||||
"""Given a descriptor, traverse all of its descendants and do metadata
|
"""Given a descriptor, traverse all of its descendants and do metadata
|
||||||
|
|||||||
@@ -12,27 +12,24 @@ structure:
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pymongo
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
import copy
|
import copy
|
||||||
|
from datetime import datetime
|
||||||
|
from importlib import import_module
|
||||||
|
import logging
|
||||||
|
import pymongo
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from bson.son import SON
|
from bson.son import SON
|
||||||
from datetime import datetime
|
from contracts import contract, new_contract
|
||||||
from fs.osfs import OSFS
|
from fs.osfs import OSFS
|
||||||
from mongodb_proxy import autoretry_read
|
from mongodb_proxy import autoretry_read
|
||||||
|
from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey
|
||||||
|
from opaque_keys.edx.locations import Location, BlockUsageLocator, SlashSeparatedCourseKey
|
||||||
|
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
|
||||||
from path import Path as path
|
from path import Path as path
|
||||||
from pytz import UTC
|
from pytz import UTC
|
||||||
from contracts import contract, new_contract
|
|
||||||
|
|
||||||
from importlib import import_module
|
|
||||||
from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey
|
|
||||||
from opaque_keys.edx.locations import Location, BlockUsageLocator
|
|
||||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
|
||||||
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
|
|
||||||
|
|
||||||
from xblock.core import XBlock
|
from xblock.core import XBlock
|
||||||
from xblock.exceptions import InvalidScopeError
|
from xblock.exceptions import InvalidScopeError
|
||||||
from xblock.fields import Scope, ScopeIds, Reference, ReferenceList, ReferenceValueDict
|
from xblock.fields import Scope, ScopeIds, Reference, ReferenceList, ReferenceValueDict
|
||||||
@@ -54,6 +51,7 @@ from xmodule.modulestore.xml import CourseLocationManager
|
|||||||
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
|
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
|
||||||
from xmodule.services import SettingsService
|
from xmodule.services import SettingsService
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
new_contract('CourseKey', CourseKey)
|
new_contract('CourseKey', CourseKey)
|
||||||
@@ -318,6 +316,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
|||||||
).replace(tzinfo=UTC)
|
).replace(tzinfo=UTC)
|
||||||
module._edit_info['published_by'] = raw_metadata.get('published_by')
|
module._edit_info['published_by'] = raw_metadata.get('published_by')
|
||||||
|
|
||||||
|
for wrapper in self.modulestore.xblock_field_data_wrappers:
|
||||||
|
module._field_data = wrapper(module, module._field_data) # pylint: disable=protected-access
|
||||||
|
|
||||||
# decache any computed pending field settings
|
# decache any computed pending field settings
|
||||||
module.save()
|
module.save()
|
||||||
return module
|
return module
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from contracts import contract, new_contract
|
from contracts import contract, new_contract
|
||||||
from fs.osfs import OSFS
|
from fs.osfs import OSFS
|
||||||
from lazy import lazy
|
from lazy import lazy
|
||||||
@@ -7,6 +8,7 @@ from xblock.runtime import KvsFieldData, KeyValueStore
|
|||||||
from xblock.fields import ScopeIds
|
from xblock.fields import ScopeIds
|
||||||
from xblock.core import XBlock
|
from xblock.core import XBlock
|
||||||
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
|
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
|
||||||
|
|
||||||
from xmodule.library_tools import LibraryToolsService
|
from xmodule.library_tools import LibraryToolsService
|
||||||
from xmodule.mako_module import MakoDescriptorSystem
|
from xmodule.mako_module import MakoDescriptorSystem
|
||||||
from xmodule.error_module import ErrorDescriptor
|
from xmodule.error_module import ErrorDescriptor
|
||||||
@@ -263,6 +265,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
|||||||
module.update_version = edit_info.update_version
|
module.update_version = edit_info.update_version
|
||||||
module.source_version = edit_info.source_version
|
module.source_version = edit_info.source_version
|
||||||
module.definition_locator = DefinitionLocator(block_key.type, definition_id)
|
module.definition_locator = DefinitionLocator(block_key.type, definition_id)
|
||||||
|
|
||||||
|
for wrapper in self.modulestore.xblock_field_data_wrappers:
|
||||||
|
module._field_data = wrapper(module, module._field_data) # pylint: disable=protected-access
|
||||||
|
|
||||||
# decache any pending field settings
|
# decache any pending field settings
|
||||||
module.save()
|
module.save()
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ package and is used to wrap the `authored_data` when constructing an
|
|||||||
`LmsFieldData`. This means overrides will be in effect for all scopes covered
|
`LmsFieldData`. This means overrides will be in effect for all scopes covered
|
||||||
by `authored_data`, e.g. course content and settings stored in Mongo.
|
by `authored_data`, e.g. course content and settings stored in Mongo.
|
||||||
"""
|
"""
|
||||||
import threading
|
|
||||||
|
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
import threading
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from request_cache.middleware import RequestCache
|
|
||||||
from xblock.field_data import FieldData
|
from xblock.field_data import FieldData
|
||||||
|
|
||||||
|
from request_cache.middleware import RequestCache
|
||||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||||
|
|
||||||
|
|
||||||
NOTSET = object()
|
NOTSET = object()
|
||||||
ENABLED_OVERRIDE_PROVIDERS_KEY = "courseware.field_overrides.enabled_providers.{course_id}"
|
ENABLED_OVERRIDE_PROVIDERS_KEY = u'courseware.field_overrides.enabled_providers.{course_id}'
|
||||||
|
ENABLED_MODULESTORE_OVERRIDE_PROVIDERS_KEY = u'courseware.modulestore_field_overrides.enabled_providers.{course_id}'
|
||||||
|
|
||||||
|
|
||||||
def resolve_dotted(name):
|
def resolve_dotted(name):
|
||||||
@@ -46,6 +49,88 @@ def resolve_dotted(name):
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage(block):
|
||||||
|
"""
|
||||||
|
Returns an iterator over all ancestors of the given block, starting with
|
||||||
|
its immediate parent and ending at the root of the block tree.
|
||||||
|
"""
|
||||||
|
parent = block.get_parent()
|
||||||
|
while parent:
|
||||||
|
yield parent
|
||||||
|
parent = parent.get_parent()
|
||||||
|
|
||||||
|
|
||||||
|
class _OverridesDisabled(threading.local):
|
||||||
|
"""
|
||||||
|
A thread local used to manage state of overrides being disabled or not.
|
||||||
|
"""
|
||||||
|
disabled = ()
|
||||||
|
|
||||||
|
|
||||||
|
_OVERRIDES_DISABLED = _OverridesDisabled()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def disable_overrides():
|
||||||
|
"""
|
||||||
|
A context manager which disables field overrides inside the context of a
|
||||||
|
`with` statement, allowing code to get at the `original` value of a field.
|
||||||
|
"""
|
||||||
|
prev = _OVERRIDES_DISABLED.disabled
|
||||||
|
_OVERRIDES_DISABLED.disabled += (True,)
|
||||||
|
yield
|
||||||
|
_OVERRIDES_DISABLED.disabled = prev
|
||||||
|
|
||||||
|
|
||||||
|
def overrides_disabled():
|
||||||
|
"""
|
||||||
|
Checks to see whether overrides are disabled in the current context.
|
||||||
|
Returns a boolean value. See `disable_overrides`.
|
||||||
|
"""
|
||||||
|
return bool(_OVERRIDES_DISABLED.disabled)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldOverrideProvider(object):
|
||||||
|
"""
|
||||||
|
Abstract class which defines the interface that a `FieldOverrideProvider`
|
||||||
|
must provide. In general, providers should derive from this class, but
|
||||||
|
it's not strictly necessary as long as they correctly implement this
|
||||||
|
interface.
|
||||||
|
|
||||||
|
A `FieldOverrideProvider` implementation is only responsible for looking up
|
||||||
|
field overrides. To set overrides, there will be a domain specific API for
|
||||||
|
the concrete override implementation being used.
|
||||||
|
"""
|
||||||
|
__metaclass__ = ABCMeta
|
||||||
|
|
||||||
|
def __init__(self, user):
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(self, block, name, default): # pragma no cover
|
||||||
|
"""
|
||||||
|
Look for an override value for the field named `name` in `block`.
|
||||||
|
Returns the overridden value or `default` if no override is found.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def enabled_for(self, course): # pragma no cover
|
||||||
|
"""
|
||||||
|
Return True if this provider should be enabled for a given course,
|
||||||
|
and False otherwise.
|
||||||
|
|
||||||
|
Concrete implementations are responsible for implementing this method.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
course (CourseModule or None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class OverrideFieldData(FieldData):
|
class OverrideFieldData(FieldData):
|
||||||
"""
|
"""
|
||||||
A :class:`~xblock.field_data.FieldData` which wraps another `FieldData`
|
A :class:`~xblock.field_data.FieldData` which wraps another `FieldData`
|
||||||
@@ -171,83 +256,54 @@ class OverrideFieldData(FieldData):
|
|||||||
return self.fallback.default(block, name)
|
return self.fallback.default(block, name)
|
||||||
|
|
||||||
|
|
||||||
class _OverridesDisabled(threading.local):
|
class OverrideModulestoreFieldData(OverrideFieldData):
|
||||||
"""
|
"""Apply field data overrides at the modulestore level. No student context required."""
|
||||||
A thread local used to manage state of overrides being disabled or not.
|
|
||||||
"""
|
|
||||||
disabled = ()
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
_OVERRIDES_DISABLED = _OverridesDisabled()
|
def wrap(cls, block, field_data): # pylint: disable=arguments-differ
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def disable_overrides():
|
|
||||||
"""
|
|
||||||
A context manager which disables field overrides inside the context of a
|
|
||||||
`with` statement, allowing code to get at the `original` value of a field.
|
|
||||||
"""
|
|
||||||
prev = _OVERRIDES_DISABLED.disabled
|
|
||||||
_OVERRIDES_DISABLED.disabled += (True,)
|
|
||||||
yield
|
|
||||||
_OVERRIDES_DISABLED.disabled = prev
|
|
||||||
|
|
||||||
|
|
||||||
def overrides_disabled():
|
|
||||||
"""
|
|
||||||
Checks to see whether overrides are disabled in the current context.
|
|
||||||
Returns a boolean value. See `disable_overrides`.
|
|
||||||
"""
|
|
||||||
return bool(_OVERRIDES_DISABLED.disabled)
|
|
||||||
|
|
||||||
|
|
||||||
class FieldOverrideProvider(object):
|
|
||||||
"""
|
|
||||||
Abstract class which defines the interface that a `FieldOverrideProvider`
|
|
||||||
must provide. In general, providers should derive from this class, but
|
|
||||||
it's not strictly necessary as long as they correctly implement this
|
|
||||||
interface.
|
|
||||||
|
|
||||||
A `FieldOverrideProvider` implementation is only responsible for looking up
|
|
||||||
field overrides. To set overrides, there will be a domain specific API for
|
|
||||||
the concrete override implementation being used.
|
|
||||||
"""
|
|
||||||
__metaclass__ = ABCMeta
|
|
||||||
|
|
||||||
def __init__(self, user):
|
|
||||||
self.user = user
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get(self, block, name, default): # pragma no cover
|
|
||||||
"""
|
"""
|
||||||
Look for an override value for the field named `name` in `block`.
|
Returns an instance of FieldData wrapped by FieldOverrideProviders which
|
||||||
Returns the overridden value or `default` if no override is found.
|
extend read-only functionality. If no MODULESTORE_FIELD_OVERRIDE_PROVIDERS
|
||||||
"""
|
are configured, an unwrapped FieldData instance is returned.
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def enabled_for(self, course): # pragma no cover
|
|
||||||
"""
|
|
||||||
Return True if this provider should be enabled for a given course,
|
|
||||||
and False otherwise.
|
|
||||||
|
|
||||||
Concrete implementations are responsible for implementing this method.
|
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
course (CourseModule or None)
|
block: An XBlock
|
||||||
|
field_data: An instance of FieldData to be wrapped
|
||||||
Returns:
|
|
||||||
bool
|
|
||||||
"""
|
"""
|
||||||
return False
|
if cls.provider_classes is None:
|
||||||
|
cls.provider_classes = [
|
||||||
|
resolve_dotted(name) for name in settings.MODULESTORE_FIELD_OVERRIDE_PROVIDERS
|
||||||
|
]
|
||||||
|
|
||||||
|
enabled_providers = cls._providers_for_block(block)
|
||||||
|
if enabled_providers:
|
||||||
|
return cls(field_data, enabled_providers)
|
||||||
|
|
||||||
def _lineage(block):
|
return field_data
|
||||||
"""
|
|
||||||
Returns an iterator over all ancestors of the given block, starting with
|
@classmethod
|
||||||
its immediate parent and ending at the root of the block tree.
|
def _providers_for_block(cls, block):
|
||||||
"""
|
"""
|
||||||
parent = block.get_parent()
|
Computes a list of enabled providers based on the given XBlock.
|
||||||
while parent:
|
The result is cached per request to avoid the overhead incurred
|
||||||
yield parent
|
by filtering override providers hundreds of times.
|
||||||
parent = parent.get_parent()
|
|
||||||
|
Arguments:
|
||||||
|
block: An XBlock
|
||||||
|
"""
|
||||||
|
course_id = unicode(block.location.course_key)
|
||||||
|
cache_key = ENABLED_MODULESTORE_OVERRIDE_PROVIDERS_KEY.format(course_id=course_id)
|
||||||
|
|
||||||
|
request_cache = RequestCache.get_request_cache()
|
||||||
|
enabled_providers = request_cache.data.get(cache_key)
|
||||||
|
|
||||||
|
if enabled_providers is None:
|
||||||
|
enabled_providers = [
|
||||||
|
provider_class for provider_class in cls.provider_classes if provider_class.enabled_for(block)
|
||||||
|
]
|
||||||
|
request_cache.data[cache_key] = enabled_providers
|
||||||
|
|
||||||
|
return enabled_providers
|
||||||
|
|
||||||
|
def __init__(self, fallback, providers):
|
||||||
|
super(OverrideModulestoreFieldData, self).__init__(None, fallback, providers)
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ class SelfPacedDateOverrideProvider(FieldOverrideProvider):
|
|||||||
# Remove release dates for course content
|
# Remove release dates for course content
|
||||||
if name == 'start' and block.category != 'course':
|
if name == 'start' and block.category != 'course':
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def enabled_for(cls, course):
|
def enabled_for(cls, block):
|
||||||
"""This provider is enabled for self-paced courses only."""
|
"""This provider is enabled for self-paced courses only."""
|
||||||
return course is not None and course.self_paced and SelfPacedConfiguration.current().enabled
|
return block is not None and block.self_paced and SelfPacedConfiguration.current().enabled
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Tests for `field_overrides` module.
|
Tests for `field_overrides` module.
|
||||||
"""
|
"""
|
||||||
|
# pylint: disable=missing-docstring
|
||||||
import unittest
|
import unittest
|
||||||
from nose.plugins.attrib import attr
|
from nose.plugins.attrib import attr
|
||||||
|
|
||||||
@@ -10,16 +11,39 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
|||||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||||
|
|
||||||
from ..field_overrides import (
|
from ..field_overrides import (
|
||||||
|
resolve_dotted,
|
||||||
disable_overrides,
|
disable_overrides,
|
||||||
FieldOverrideProvider,
|
FieldOverrideProvider,
|
||||||
OverrideFieldData,
|
OverrideFieldData,
|
||||||
resolve_dotted,
|
OverrideModulestoreFieldData,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
TESTUSER = "testuser"
|
TESTUSER = "testuser"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOverrideProvider(FieldOverrideProvider):
|
||||||
|
"""
|
||||||
|
A concrete implementation of `FieldOverrideProvider` for testing.
|
||||||
|
"""
|
||||||
|
def get(self, block, name, default):
|
||||||
|
if self.user:
|
||||||
|
assert self.user is TESTUSER
|
||||||
|
|
||||||
|
assert block == 'block'
|
||||||
|
|
||||||
|
if name == 'foo':
|
||||||
|
return 'fu'
|
||||||
|
elif name == 'oh':
|
||||||
|
return 'man'
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def enabled_for(cls, course):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@attr('shard_1')
|
@attr('shard_1')
|
||||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
||||||
'courseware.tests.test_field_overrides.TestOverrideProvider',))
|
'courseware.tests.test_field_overrides.TestOverrideProvider',))
|
||||||
@@ -100,6 +124,31 @@ class OverrideFieldDataTests(SharedModuleStoreTestCase):
|
|||||||
self.assertIsInstance(data, DictFieldData)
|
self.assertIsInstance(data, DictFieldData)
|
||||||
|
|
||||||
|
|
||||||
|
@attr('shard_1')
|
||||||
|
@override_settings(
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS=['courseware.tests.test_field_overrides.TestOverrideProvider']
|
||||||
|
)
|
||||||
|
class OverrideModulestoreFieldDataTests(OverrideFieldDataTests):
|
||||||
|
def setUp(self):
|
||||||
|
super(OverrideModulestoreFieldDataTests, self).setUp()
|
||||||
|
OverrideModulestoreFieldData.provider_classes = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
super(OverrideModulestoreFieldDataTests, self).tearDown()
|
||||||
|
OverrideModulestoreFieldData.provider_classes = None
|
||||||
|
|
||||||
|
def make_one(self):
|
||||||
|
return OverrideModulestoreFieldData.wrap(self.course, DictFieldData({
|
||||||
|
'foo': 'bar',
|
||||||
|
'bees': 'knees',
|
||||||
|
}))
|
||||||
|
|
||||||
|
@override_settings(MODULESTORE_FIELD_OVERRIDE_PROVIDERS=[])
|
||||||
|
def test_no_overrides_configured(self):
|
||||||
|
data = self.make_one()
|
||||||
|
self.assertIsInstance(data, DictFieldData)
|
||||||
|
|
||||||
|
|
||||||
@attr('shard_1')
|
@attr('shard_1')
|
||||||
class ResolveDottedTests(unittest.TestCase):
|
class ResolveDottedTests(unittest.TestCase):
|
||||||
"""
|
"""
|
||||||
@@ -121,24 +170,6 @@ class ResolveDottedTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestOverrideProvider(FieldOverrideProvider):
|
|
||||||
"""
|
|
||||||
A concrete implementation of `FieldOverrideProvider` for testing.
|
|
||||||
"""
|
|
||||||
def get(self, block, name, default):
|
|
||||||
assert self.user is TESTUSER
|
|
||||||
assert block == 'block'
|
|
||||||
if name == 'foo':
|
|
||||||
return 'fu'
|
|
||||||
if name == 'oh':
|
|
||||||
return 'man'
|
|
||||||
return default
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def enabled_for(cls, course):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def inject_field_overrides(blocks, course, user):
|
def inject_field_overrides(blocks, course, user):
|
||||||
"""
|
"""
|
||||||
Apparently the test harness doesn't use LmsFieldStorage, and I'm
|
Apparently the test harness doesn't use LmsFieldStorage, and I'm
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""
|
"""Tests for self-paced course due date overrides."""
|
||||||
Tests for self-paced course due date overrides.
|
# pylint: disable=missing-docstring
|
||||||
"""
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
@@ -11,17 +9,17 @@ from mock import patch
|
|||||||
|
|
||||||
from courseware.tests.factories import BetaTesterFactory
|
from courseware.tests.factories import BetaTesterFactory
|
||||||
from courseware.access import has_access
|
from courseware.access import has_access
|
||||||
|
|
||||||
from lms.djangoapps.ccx.tests.test_overrides import inject_field_overrides
|
from lms.djangoapps.ccx.tests.test_overrides import inject_field_overrides
|
||||||
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
|
from lms.djangoapps.django_comment_client.utils import get_accessible_discussion_modules
|
||||||
|
from lms.djangoapps.courseware.field_overrides import OverrideFieldData, OverrideModulestoreFieldData
|
||||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||||
|
|
||||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
FIELD_OVERRIDE_PROVIDERS=('courseware.self_paced_overrides.SelfPacedDateOverrideProvider',)
|
XBLOCK_FIELD_DATA_WRAPPERS=['lms.djangoapps.courseware.field_overrides:OverrideModulestoreFieldData.wrap'],
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS=['courseware.self_paced_overrides.SelfPacedDateOverrideProvider'],
|
||||||
)
|
)
|
||||||
class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
||||||
"""
|
"""
|
||||||
@@ -29,14 +27,19 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
SelfPacedConfiguration(enabled=True).save()
|
|
||||||
super(SelfPacedDateOverrideTest, self).setUp()
|
super(SelfPacedDateOverrideTest, self).setUp()
|
||||||
self.due_date = datetime.datetime(2015, 5, 26, 8, 30, 00).replace(tzinfo=tzutc())
|
|
||||||
|
SelfPacedConfiguration(enabled=True).save()
|
||||||
|
|
||||||
self.non_staff_user, __ = self.create_non_staff_user()
|
self.non_staff_user, __ = self.create_non_staff_user()
|
||||||
|
self.now = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
|
||||||
|
self.future = self.now + datetime.timedelta(days=30)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
super(SelfPacedDateOverrideTest, self).tearDown()
|
super(SelfPacedDateOverrideTest, self).tearDown()
|
||||||
|
|
||||||
OverrideFieldData.provider_classes = None
|
OverrideFieldData.provider_classes = None
|
||||||
|
OverrideModulestoreFieldData.provider_classes = None
|
||||||
|
|
||||||
def setup_course(self, **course_kwargs):
|
def setup_course(self, **course_kwargs):
|
||||||
"""Set up a course with provided course attributes.
|
"""Set up a course with provided course attributes.
|
||||||
@@ -45,22 +48,39 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
|||||||
overrides are correctly applied for both blocks.
|
overrides are correctly applied for both blocks.
|
||||||
"""
|
"""
|
||||||
course = CourseFactory.create(**course_kwargs)
|
course = CourseFactory.create(**course_kwargs)
|
||||||
section = ItemFactory.create(parent=course, due=self.due_date)
|
section = ItemFactory.create(parent=course, due=self.now)
|
||||||
inject_field_overrides((course, section), course, self.user)
|
inject_field_overrides((course, section), course, self.user)
|
||||||
return (course, section)
|
return (course, section)
|
||||||
|
|
||||||
def test_instructor_paced(self):
|
def create_discussion_modules(self, parent):
|
||||||
__, ip_section = self.setup_course(display_name="Instructor Paced Course", self_paced=False)
|
# Create a released discussion module
|
||||||
self.assertEqual(self.due_date, ip_section.due)
|
ItemFactory.create(
|
||||||
|
parent=parent,
|
||||||
|
category='discussion',
|
||||||
|
display_name='released',
|
||||||
|
start=self.now,
|
||||||
|
)
|
||||||
|
|
||||||
def test_self_paced(self):
|
# Create a scheduled discussion module
|
||||||
|
ItemFactory.create(
|
||||||
|
parent=parent,
|
||||||
|
category='discussion',
|
||||||
|
display_name='scheduled',
|
||||||
|
start=self.future,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_instructor_paced_due_date(self):
|
||||||
|
__, ip_section = self.setup_course(display_name="Instructor Paced Course", self_paced=False)
|
||||||
|
self.assertEqual(ip_section.due, self.now)
|
||||||
|
|
||||||
|
def test_self_paced_due_date(self):
|
||||||
__, sp_section = self.setup_course(display_name="Self-Paced Course", self_paced=True)
|
__, sp_section = self.setup_course(display_name="Self-Paced Course", self_paced=True)
|
||||||
self.assertIsNone(sp_section.due)
|
self.assertIsNone(sp_section.due)
|
||||||
|
|
||||||
def test_self_paced_disabled(self):
|
def test_self_paced_disabled_due_date(self):
|
||||||
SelfPacedConfiguration(enabled=False).save()
|
SelfPacedConfiguration(enabled=False).save()
|
||||||
__, sp_section = self.setup_course(display_name="Self-Paced Course", self_paced=True)
|
__, sp_section = self.setup_course(display_name="Self-Paced Course", self_paced=True)
|
||||||
self.assertEqual(self.due_date, sp_section.due)
|
self.assertEqual(sp_section.due, self.now)
|
||||||
|
|
||||||
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
|
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
|
||||||
def test_course_access_to_beta_users(self):
|
def test_course_access_to_beta_users(self):
|
||||||
@@ -89,3 +109,34 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
|||||||
# Verify beta tester can access the course as well as the course sections
|
# Verify beta tester can access the course as well as the course sections
|
||||||
self.assertTrue(has_access(beta_tester, 'load', self_paced_course))
|
self.assertTrue(has_access(beta_tester, 'load', self_paced_course))
|
||||||
self.assertTrue(has_access(beta_tester, 'load', self_paced_section, self_paced_course.id))
|
self.assertTrue(has_access(beta_tester, 'load', self_paced_section, self_paced_course.id))
|
||||||
|
|
||||||
|
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
|
||||||
|
def test_instructor_paced_discussion_module_visibility(self):
|
||||||
|
"""
|
||||||
|
Verify that discussion modules scheduled for release in the future are
|
||||||
|
not visible to students in an instructor-paced course.
|
||||||
|
"""
|
||||||
|
course, section = self.setup_course(start=self.now, self_paced=False)
|
||||||
|
self.create_discussion_modules(section)
|
||||||
|
|
||||||
|
# Only the released module should be visible when the course is instructor-paced.
|
||||||
|
modules = get_accessible_discussion_modules(course, self.non_staff_user)
|
||||||
|
self.assertTrue(
|
||||||
|
all(module.display_name == 'released' for module in modules)
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
|
||||||
|
def test_self_paced_discussion_module_visibility(self):
|
||||||
|
"""
|
||||||
|
Regression test. Verify that discussion modules scheduled for release
|
||||||
|
in the future are visible to students in a self-paced course.
|
||||||
|
"""
|
||||||
|
course, section = self.setup_course(start=self.now, self_paced=True)
|
||||||
|
self.create_discussion_modules(section)
|
||||||
|
|
||||||
|
# The scheduled module should be visible when the course is self-paced.
|
||||||
|
modules = get_accessible_discussion_modules(course, self.non_staff_user)
|
||||||
|
self.assertEqual(len(modules), 2)
|
||||||
|
self.assertTrue(
|
||||||
|
any(module.display_name == 'scheduled' for module in modules)
|
||||||
|
)
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ if FEATURES.get('ENABLE_CORS_HEADERS') or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF
|
|||||||
CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN')
|
CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN')
|
||||||
|
|
||||||
|
|
||||||
# Field overrides. To use the IDDE feature, add
|
# Field overrides. To use the IDDE feature, add
|
||||||
# 'courseware.student_field_overrides.IndividualStudentOverrideProvider'.
|
# 'courseware.student_field_overrides.IndividualStudentOverrideProvider'.
|
||||||
FIELD_OVERRIDE_PROVIDERS = tuple(ENV_TOKENS.get('FIELD_OVERRIDE_PROVIDERS', []))
|
FIELD_OVERRIDE_PROVIDERS = tuple(ENV_TOKENS.get('FIELD_OVERRIDE_PROVIDERS', []))
|
||||||
|
|
||||||
@@ -406,6 +406,16 @@ if 'DJFS' in AUTH_TOKENS and AUTH_TOKENS['DJFS'] is not None:
|
|||||||
############### Module Store Items ##########
|
############### Module Store Items ##########
|
||||||
HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS', {})
|
HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS', {})
|
||||||
|
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ENV_TOKENS.get(
|
||||||
|
'MODULESTORE_FIELD_OVERRIDE_PROVIDERS',
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS
|
||||||
|
)
|
||||||
|
|
||||||
|
XBLOCK_FIELD_DATA_WRAPPERS = ENV_TOKENS.get(
|
||||||
|
'XBLOCK_FIELD_DATA_WRAPPERS',
|
||||||
|
XBLOCK_FIELD_DATA_WRAPPERS
|
||||||
|
)
|
||||||
|
|
||||||
############### Mixed Related(Secure/Not-Secure) Items ##########
|
############### Mixed Related(Secure/Not-Secure) Items ##########
|
||||||
LMS_SEGMENT_KEY = AUTH_TOKENS.get('SEGMENT_KEY')
|
LMS_SEGMENT_KEY = AUTH_TOKENS.get('SEGMENT_KEY')
|
||||||
|
|
||||||
@@ -693,7 +703,11 @@ if FEATURES.get('INDIVIDUAL_DUE_DATES'):
|
|||||||
)
|
)
|
||||||
|
|
||||||
##### Self-Paced Course Due Dates #####
|
##### Self-Paced Course Due Dates #####
|
||||||
FIELD_OVERRIDE_PROVIDERS += (
|
XBLOCK_FIELD_DATA_WRAPPERS += (
|
||||||
|
'lms.djangoapps.courseware.field_overrides:OverrideModulestoreFieldData.wrap',
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS += (
|
||||||
'courseware.self_paced_overrides.SelfPacedDateOverrideProvider',
|
'courseware.self_paced_overrides.SelfPacedDateOverrideProvider',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -694,6 +694,9 @@ XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin)
|
|||||||
# Allow any XBlock in the LMS
|
# Allow any XBlock in the LMS
|
||||||
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||||
|
|
||||||
|
# Paths to wrapper methods which should be applied to every XBlock's FieldData.
|
||||||
|
XBLOCK_FIELD_DATA_WRAPPERS = ()
|
||||||
|
|
||||||
############# ModuleStore Configuration ##########
|
############# ModuleStore Configuration ##########
|
||||||
|
|
||||||
MODULESTORE_BRANCH = 'published-only'
|
MODULESTORE_BRANCH = 'published-only'
|
||||||
@@ -2643,6 +2646,10 @@ CHECKPOINT_PATTERN = r'(?P<checkpoint_name>[^/]+)'
|
|||||||
# this setting.
|
# this setting.
|
||||||
FIELD_OVERRIDE_PROVIDERS = ()
|
FIELD_OVERRIDE_PROVIDERS = ()
|
||||||
|
|
||||||
|
# Modulestore-level field override providers. These field override providers don't
|
||||||
|
# require student context.
|
||||||
|
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ()
|
||||||
|
|
||||||
# PROFILE IMAGE CONFIG
|
# PROFILE IMAGE CONFIG
|
||||||
# WARNING: Certain django storage backends do not support atomic
|
# WARNING: Certain django storage backends do not support atomic
|
||||||
# file overwrites (including the default, OverwriteStorage) - instead
|
# file overwrites (including the default, OverwriteStorage) - instead
|
||||||
|
|||||||
Reference in New Issue
Block a user