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']
|
||||
|
||||
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']
|
||||
DOC_STORE_CONFIG = AUTH_TOKENS['DOC_STORE_CONFIG']
|
||||
# Datadog for events!
|
||||
|
||||
@@ -383,6 +383,9 @@ XBLOCK_MIXINS = (
|
||||
|
||||
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_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 ############################################
|
||||
|
||||
CODE_JAIL = {
|
||||
|
||||
@@ -1160,7 +1160,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
|
||||
contentstore=None,
|
||||
doc_store_config=None, # ignore if passed up
|
||||
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
|
||||
db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None,
|
||||
# allow lower level init args to pass harmlessly
|
||||
@@ -1177,6 +1177,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
|
||||
self.request_cache = request_cache
|
||||
self.xblock_mixins = xblock_mixins
|
||||
self.xblock_select = xblock_select
|
||||
self.xblock_field_data_wrappers = xblock_field_data_wrappers
|
||||
self.disabled_xblock_types = disabled_xblock_types
|
||||
self.contentstore = contentstore
|
||||
|
||||
|
||||
@@ -120,11 +120,25 @@ def load_function(path):
|
||||
"""
|
||||
Load a function by name.
|
||||
|
||||
path is a string of the form "path.to.module.function"
|
||||
returns the imported python object `function` from `path.to.module`
|
||||
Arguments:
|
||||
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('.')
|
||||
return getattr(import_module(module_path), name)
|
||||
if ':' in path:
|
||||
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(
|
||||
@@ -179,12 +193,15 @@ def create_modulestore_instance(
|
||||
else:
|
||||
disabled_xblock_types = ()
|
||||
|
||||
xblock_field_data_wrappers = [load_function(path) for path in settings.XBLOCK_FIELD_DATA_WRAPPERS]
|
||||
|
||||
return class_(
|
||||
contentstore=content_store,
|
||||
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
|
||||
request_cache=request_cache,
|
||||
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
|
||||
xblock_select=getattr(settings, 'XBLOCK_SELECT_FUNCTION', None),
|
||||
xblock_field_data_wrappers=xblock_field_data_wrappers,
|
||||
disabled_xblock_types=disabled_xblock_types,
|
||||
doc_store_config=doc_store_config,
|
||||
i18n_service=i18n_service or ModuleI18nService(),
|
||||
|
||||
@@ -218,6 +218,17 @@ class InheritanceMixin(XBlockMixin):
|
||||
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):
|
||||
"""Given a descriptor, traverse all of its descendants and do metadata
|
||||
|
||||
@@ -12,27 +12,24 @@ structure:
|
||||
}
|
||||
"""
|
||||
|
||||
import pymongo
|
||||
import sys
|
||||
import logging
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
import logging
|
||||
import pymongo
|
||||
import re
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
|
||||
from bson.son import SON
|
||||
from datetime import datetime
|
||||
from contracts import contract, new_contract
|
||||
from fs.osfs import OSFS
|
||||
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 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.exceptions import InvalidScopeError
|
||||
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.services import SettingsService
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
new_contract('CourseKey', CourseKey)
|
||||
@@ -318,6 +316,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
).replace(tzinfo=UTC)
|
||||
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
|
||||
module.save()
|
||||
return module
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from contracts import contract, new_contract
|
||||
from fs.osfs import OSFS
|
||||
from lazy import lazy
|
||||
@@ -7,6 +8,7 @@ from xblock.runtime import KvsFieldData, KeyValueStore
|
||||
from xblock.fields import ScopeIds
|
||||
from xblock.core import XBlock
|
||||
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
|
||||
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.mako_module import MakoDescriptorSystem
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
@@ -263,6 +265,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
module.update_version = edit_info.update_version
|
||||
module.source_version = edit_info.source_version
|
||||
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
|
||||
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
|
||||
by `authored_data`, e.g. course content and settings stored in Mongo.
|
||||
"""
|
||||
import threading
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
import threading
|
||||
|
||||
from django.conf import settings
|
||||
from request_cache.middleware import RequestCache
|
||||
from xblock.field_data import FieldData
|
||||
|
||||
from request_cache.middleware import RequestCache
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
|
||||
|
||||
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):
|
||||
@@ -46,6 +49,88 @@ def resolve_dotted(name):
|
||||
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):
|
||||
"""
|
||||
A :class:`~xblock.field_data.FieldData` which wraps another `FieldData`
|
||||
@@ -171,83 +256,54 @@ class OverrideFieldData(FieldData):
|
||||
return self.fallback.default(block, name)
|
||||
|
||||
|
||||
class _OverridesDisabled(threading.local):
|
||||
"""
|
||||
A thread local used to manage state of overrides being disabled or not.
|
||||
"""
|
||||
disabled = ()
|
||||
class OverrideModulestoreFieldData(OverrideFieldData):
|
||||
"""Apply field data overrides at the modulestore level. No student context required."""
|
||||
|
||||
|
||||
_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
|
||||
@classmethod
|
||||
def wrap(cls, block, field_data): # pylint: disable=arguments-differ
|
||||
"""
|
||||
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.
|
||||
Returns an instance of FieldData wrapped by FieldOverrideProviders which
|
||||
extend read-only functionality. If no MODULESTORE_FIELD_OVERRIDE_PROVIDERS
|
||||
are configured, an unwrapped FieldData instance is returned.
|
||||
|
||||
Arguments:
|
||||
course (CourseModule or None)
|
||||
|
||||
Returns:
|
||||
bool
|
||||
block: An XBlock
|
||||
field_data: An instance of FieldData to be wrapped
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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()
|
||||
return field_data
|
||||
|
||||
@classmethod
|
||||
def _providers_for_block(cls, block):
|
||||
"""
|
||||
Computes a list of enabled providers based on the given XBlock.
|
||||
The result is cached per request to avoid the overhead incurred
|
||||
by filtering override providers hundreds of times.
|
||||
|
||||
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
|
||||
if name == 'start' and block.category != 'course':
|
||||
return None
|
||||
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def enabled_for(cls, course):
|
||||
def enabled_for(cls, block):
|
||||
"""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.
|
||||
"""
|
||||
# pylint: disable=missing-docstring
|
||||
import unittest
|
||||
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 ..field_overrides import (
|
||||
resolve_dotted,
|
||||
disable_overrides,
|
||||
FieldOverrideProvider,
|
||||
OverrideFieldData,
|
||||
resolve_dotted,
|
||||
OverrideModulestoreFieldData,
|
||||
)
|
||||
|
||||
|
||||
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')
|
||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
||||
'courseware.tests.test_field_overrides.TestOverrideProvider',))
|
||||
@@ -100,6 +124,31 @@ class OverrideFieldDataTests(SharedModuleStoreTestCase):
|
||||
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')
|
||||
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):
|
||||
"""
|
||||
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 pytz
|
||||
|
||||
@@ -11,17 +9,17 @@ from mock import patch
|
||||
|
||||
from courseware.tests.factories import BetaTesterFactory
|
||||
from courseware.access import has_access
|
||||
|
||||
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 xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
@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):
|
||||
"""
|
||||
@@ -29,14 +27,19 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
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.now = datetime.datetime.now(pytz.UTC).replace(microsecond=0)
|
||||
self.future = self.now + datetime.timedelta(days=30)
|
||||
|
||||
def tearDown(self):
|
||||
super(SelfPacedDateOverrideTest, self).tearDown()
|
||||
|
||||
OverrideFieldData.provider_classes = None
|
||||
OverrideModulestoreFieldData.provider_classes = None
|
||||
|
||||
def setup_course(self, **course_kwargs):
|
||||
"""Set up a course with provided course attributes.
|
||||
@@ -45,22 +48,39 @@ class SelfPacedDateOverrideTest(ModuleStoreTestCase):
|
||||
overrides are correctly applied for both blocks.
|
||||
"""
|
||||
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)
|
||||
return (course, section)
|
||||
|
||||
def test_instructor_paced(self):
|
||||
__, ip_section = self.setup_course(display_name="Instructor Paced Course", self_paced=False)
|
||||
self.assertEqual(self.due_date, ip_section.due)
|
||||
def create_discussion_modules(self, parent):
|
||||
# Create a released discussion module
|
||||
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)
|
||||
self.assertIsNone(sp_section.due)
|
||||
|
||||
def test_self_paced_disabled(self):
|
||||
def test_self_paced_disabled_due_date(self):
|
||||
SelfPacedConfiguration(enabled=False).save()
|
||||
__, 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})
|
||||
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
|
||||
self.assertTrue(has_access(beta_tester, 'load', self_paced_course))
|
||||
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')
|
||||
|
||||
|
||||
# Field overrides. To use the IDDE feature, add
|
||||
# Field overrides. To use the IDDE feature, add
|
||||
# 'courseware.student_field_overrides.IndividualStudentOverrideProvider'.
|
||||
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 ##########
|
||||
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 ##########
|
||||
LMS_SEGMENT_KEY = AUTH_TOKENS.get('SEGMENT_KEY')
|
||||
|
||||
@@ -693,7 +703,11 @@ if FEATURES.get('INDIVIDUAL_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',
|
||||
)
|
||||
|
||||
|
||||
@@ -694,6 +694,9 @@ XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin)
|
||||
# Allow any XBlock in the LMS
|
||||
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_BRANCH = 'published-only'
|
||||
@@ -2643,6 +2646,10 @@ CHECKPOINT_PATTERN = r'(?P<checkpoint_name>[^/]+)'
|
||||
# this setting.
|
||||
FIELD_OVERRIDE_PROVIDERS = ()
|
||||
|
||||
# Modulestore-level field override providers. These field override providers don't
|
||||
# require student context.
|
||||
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ()
|
||||
|
||||
# PROFILE IMAGE CONFIG
|
||||
# WARNING: Certain django storage backends do not support atomic
|
||||
# file overwrites (including the default, OverwriteStorage) - instead
|
||||
|
||||
Reference in New Issue
Block a user