diff --git a/cms/envs/aws.py b/cms/envs/aws.py index cc45b632e1..a924de3018 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -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! diff --git a/cms/envs/common.py b/cms/envs/common.py index de6db81d21..dff2231171 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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 = { diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 278a32fc4a..a23ae9770b 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -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 diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index c7ddd95a1c..3709d8858a 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -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(), diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 6002a14357..07d9f98cc4 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -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 diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index ba82915b26..161899f968 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -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 diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py index d7802e9462..202d32ef1a 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/caching_descriptor_system.py @@ -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() diff --git a/lms/djangoapps/courseware/field_overrides.py b/lms/djangoapps/courseware/field_overrides.py index 9104fd5e33..02417d9515 100644 --- a/lms/djangoapps/courseware/field_overrides.py +++ b/lms/djangoapps/courseware/field_overrides.py @@ -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) diff --git a/lms/djangoapps/courseware/self_paced_overrides.py b/lms/djangoapps/courseware/self_paced_overrides.py index e694705205..9ca19d6810 100644 --- a/lms/djangoapps/courseware/self_paced_overrides.py +++ b/lms/djangoapps/courseware/self_paced_overrides.py @@ -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 diff --git a/lms/djangoapps/courseware/tests/test_field_overrides.py b/lms/djangoapps/courseware/tests/test_field_overrides.py index 70eb9660f3..d4cde3f8d3 100644 --- a/lms/djangoapps/courseware/tests/test_field_overrides.py +++ b/lms/djangoapps/courseware/tests/test_field_overrides.py @@ -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 diff --git a/lms/djangoapps/courseware/tests/test_self_paced_overrides.py b/lms/djangoapps/courseware/tests/test_self_paced_overrides.py index eda32b1140..1a1abcacc8 100644 --- a/lms/djangoapps/courseware/tests/test_self_paced_overrides.py +++ b/lms/djangoapps/courseware/tests/test_self_paced_overrides.py @@ -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) + ) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index fac0fc2bed..e0b1270dd0 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -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', ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 4437ccdf2d..e944c20a0c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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[^/]+)' # 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