Commit a5b10ca0 by Renzo Lucioni

Merge pull request #11805 from edx/renzo/self-paced-modulestore-wrapper

Override field data within the XBlock runtime
parents 7eb079df cd9986b6
...@@ -283,6 +283,17 @@ else: ...@@ -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 = ( ...@@ -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 = { ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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'.
""" """
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('.') module_path, _, name = path.rpartition('.')
return getattr(import_module(module_path), name) 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( ...@@ -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): ...@@ -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: ...@@ -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 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.keys import UsageKey, CourseKey, AssetKey
from opaque_keys.edx.locations import Location, BlockUsageLocator from opaque_keys.edx.locations import Location, BlockUsageLocator, SlashSeparatedCourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator, LibraryLocator from opaque_keys.edx.locator import CourseLocator, LibraryLocator
from path import Path as path
from pytz import UTC
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 ...@@ -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): ...@@ -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
......
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 ...@@ -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): ...@@ -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 ...@@ -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): ...@@ -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): ...@@ -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 = ()
_OVERRIDES_DISABLED = _OverridesDisabled()
@contextmanager @classmethod
def disable_overrides(): def wrap(cls, block, field_data): # pylint: disable=arguments-differ
"""
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) 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:
class FieldOverrideProvider(object): block: An XBlock
field_data: An instance of FieldData to be wrapped
""" """
Abstract class which defines the interface that a `FieldOverrideProvider` if cls.provider_classes is None:
must provide. In general, providers should derive from this class, but cls.provider_classes = [
it's not strictly necessary as long as they correctly implement this resolve_dotted(name) for name in settings.MODULESTORE_FIELD_OVERRIDE_PROVIDERS
interface. ]
A `FieldOverrideProvider` implementation is only responsible for looking up enabled_providers = cls._providers_for_block(block)
field overrides. To set overrides, there will be a domain specific API for if enabled_providers:
the concrete override implementation being used. return cls(field_data, enabled_providers)
"""
__metaclass__ = ABCMeta
def __init__(self, user): return field_data
self.user = user
@abstractmethod @classmethod
def get(self, block, name, default): # pragma no cover def _providers_for_block(cls, block):
"""
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 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.
@abstractmethod Arguments:
def enabled_for(self, course): # pragma no cover block: An XBlock
""" """
Return True if this provider should be enabled for a given course, course_id = unicode(block.location.course_key)
and False otherwise. cache_key = ENABLED_MODULESTORE_OVERRIDE_PROVIDERS_KEY.format(course_id=course_id)
Concrete implementations are responsible for implementing this method. request_cache = RequestCache.get_request_cache()
enabled_providers = request_cache.data.get(cache_key)
Arguments:
course (CourseModule or None)
Returns: if enabled_providers is None:
bool enabled_providers = [
""" provider_class for provider_class in cls.provider_classes if provider_class.enabled_for(block)
return False ]
request_cache.data[cache_key] = enabled_providers
return enabled_providers
def _lineage(block): def __init__(self, fallback, providers):
""" super(OverrideModulestoreFieldData, self).__init__(None, fallback, providers)
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()
...@@ -20,9 +20,10 @@ class SelfPacedDateOverrideProvider(FieldOverrideProvider): ...@@ -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
""" """
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 ...@@ -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',))
...@@ -101,6 +125,31 @@ class OverrideFieldDataTests(SharedModuleStoreTestCase): ...@@ -101,6 +125,31 @@ class OverrideFieldDataTests(SharedModuleStoreTestCase):
@attr('shard_1') @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): class ResolveDottedTests(unittest.TestCase):
""" """
Tests for `resolve_dotted`. Tests for `resolve_dotted`.
...@@ -121,24 +170,6 @@ 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
......
""" """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 ...@@ -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): ...@@ -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): ...@@ -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):
# Create a released discussion module
ItemFactory.create(
parent=parent,
category='discussion',
display_name='released',
start=self.now,
)
# 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) __, ip_section = self.setup_course(display_name="Instructor Paced Course", self_paced=False)
self.assertEqual(self.due_date, ip_section.due) self.assertEqual(ip_section.due, self.now)
def test_self_paced(self): 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): ...@@ -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)
)
...@@ -406,6 +406,16 @@ if 'DJFS' in AUTH_TOKENS and AUTH_TOKENS['DJFS'] is not None: ...@@ -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'): ...@@ -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) ...@@ -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'
...@@ -2644,6 +2647,10 @@ CHECKPOINT_PATTERN = r'(?P<checkpoint_name>[^/]+)' ...@@ -2644,6 +2647,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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment