diff --git a/AUTHORS b/AUTHORS
index d1fe5e8d4162..fe518862268a 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -204,3 +204,7 @@ Matjaz Gregoric
Kyle Boots
John Espinosa
Phil McGachey
+Sri Harsha Pamu
+Cris Ewing
+Carlos de La Guardia
+Amir Qayyum Khan
\ No newline at end of file
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 60bacea09319..cc9e0c1cef8f 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -13,7 +13,7 @@ Step 0: Join the Conversation
Got an idea for how to improve the codebase? Fantastic, we'd love to hear about
it! Before you dive in and spend a lot of time and effort making a pull request,
-it's a good idea to discuss your idea with other interested developers and/or the
+it's a good idea to discuss your idea with other interested developers and/or the
edX product team. You may get some valuable feedback that changes how you think
about your idea, or you may find other developers who have the same idea and want
to work together.
@@ -66,14 +66,14 @@ For asynchronous conversation, we have several mailing lists on Google Groups:
Byte-sized Tasks & Bugs
-----------------------
-If you are contributing for the first time and want a gentle introduction,
+If you are contributing for the first time and want a gentle introduction,
or if you aren't sure what to work on, have a look at the list of
`byte-sized bugs and tasks`_ in the tracker. These tasks are selected for their
small size, and usually don't require a broad knowledge of the edX platform.
It makes them good candidates for a first task, allowing you to focus on getting
familiar with the development environment and the contribution process.
-.. _byte-sized bugs and tasks: https://openedx.atlassian.net/issues/?filter=12810
+.. _byte-sized bugs and tasks: http://bit.ly/edxbugs
Once you have identified a bug or task, `create an account on the tracker`_ and
then comment on the ticket to indicate that you are working on it. Don't hesitate
@@ -140,12 +140,9 @@ Step 5: Code Review by Core Committer(s)
If your pull request meets the requirements listed in the
`contributor documentation`_, and it hasn't been rejected by a product owner,
then it will be scheduled for code review by one or more core committers. This
-process sometimes takes awhile: currently, all core committers on the project
+process sometimes takes awhile: most of the core committers on the project
are employees of edX, and they have to balance their time between code review
-and new development. Please also read our `code ownership page`_, which
-lists areas and concepts in the codebase that are "owned" by certain developers.
-If your change touches one of these areas or concepts, that developer should be
-one of the reviewers.
+and new development.
Once the code review process has started, please be responsive to comments on
the pull request, so we can keep the review process moving forward.
@@ -153,8 +150,6 @@ If you are unable to respond for a few days, that's fine, but
please add a comment informing us of that -- otherwise, it looks like you're
abandoning your work!
-.. _code ownership page: https://github.com/edx/edx-platform/wiki/Code-Ownership
-
Step 6: Merge!
==============
diff --git a/cms/djangoapps/contentstore/__init__.py b/cms/djangoapps/contentstore/__init__.py
index e69de29bb2d1..d3060371e5d8 100644
--- a/cms/djangoapps/contentstore/__init__.py
+++ b/cms/djangoapps/contentstore/__init__.py
@@ -0,0 +1,2 @@
+""" module init will register signal handlers """
+import contentstore.signals
diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py
index 6fdb475b379e..21904959f8e6 100644
--- a/cms/djangoapps/contentstore/admin.py
+++ b/cms/djangoapps/contentstore/admin.py
@@ -5,6 +5,7 @@
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
-from contentstore.models import VideoUploadConfig
+from contentstore.models import VideoUploadConfig, PushNotificationConfig
admin.site.register(VideoUploadConfig, ConfigurationModelAdmin)
+admin.site.register(PushNotificationConfig, ConfigurationModelAdmin)
diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py
index 692f7a9dd443..7ba75ff21559 100644
--- a/cms/djangoapps/contentstore/course_info_model.py
+++ b/cms/djangoapps/contentstore/course_info_model.py
@@ -23,6 +23,7 @@
from xmodule.html_module import CourseInfoModule
from xmodule_modifiers import get_course_update_items
+from cms.djangoapps.contentstore.push_notification import enqueue_push_course_update
# # This should be in a class which inherits from XmlDescriptor
log = logging.getLogger(__name__)
@@ -44,9 +45,13 @@ def get_course_updates(location, provided_id, user_id):
def update_course_updates(location, update, passed_id=None, user=None):
"""
- Either add or update the given course update. It will add it if the passed_id is absent or None. It will update it if
- it has an passed_id which has a valid value. Until updates have distinct values, the passed_id is the location url + an index
- into the html structure.
+ Either add or update the given course update.
+ Add:
+ If the passed_id is absent or None, the course update is added.
+ If push_notification_selected is set in the update, a celery task for the push notification is created.
+ Update:
+ It will update it if it has a passed_id which has a valid value.
+ Until updates have distinct values, the passed_id is the location url + an index into the html structure.
"""
try:
course_updates = modulestore().get_item(location)
@@ -73,6 +78,7 @@ def update_course_updates(location, update, passed_id=None, user=None):
"status": CourseInfoModule.STATUS_VISIBLE
}
course_update_items.append(course_update_dict)
+ enqueue_push_course_update(update, location.course_key)
# update db record
save_course_update_items(location, course_updates, course_update_items, user)
diff --git a/cms/djangoapps/contentstore/courseware_index.py b/cms/djangoapps/contentstore/courseware_index.py
new file mode 100644
index 000000000000..ec953eb5c58b
--- /dev/null
+++ b/cms/djangoapps/contentstore/courseware_index.py
@@ -0,0 +1,302 @@
+""" Code to allow module store to interface with courseware index """
+from __future__ import absolute_import
+from abc import ABCMeta, abstractmethod
+from datetime import timedelta
+import logging
+from six import add_metaclass
+
+from django.conf import settings
+from django.utils.translation import ugettext as _
+from eventtracking import tracker
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.library_tools import normalize_key_for_search
+from search.search_engine_base import SearchEngine
+
+# REINDEX_AGE is the default amount of time that we look back for changes
+# that might have happened. If we are provided with a time at which the
+# indexing is triggered, then we know it is safe to only index items
+# recently changed at that time. This is the time period that represents
+# how far back from the trigger point to look back in order to index
+REINDEX_AGE = timedelta(0, 60) # 60 seconds
+
+log = logging.getLogger('edx.modulestore')
+
+
+class SearchIndexingError(Exception):
+ """ Indicates some error(s) occured during indexing """
+
+ def __init__(self, message, error_list):
+ super(SearchIndexingError, self).__init__(message)
+ self.error_list = error_list
+
+
+@add_metaclass(ABCMeta)
+class SearchIndexerBase(object):
+ """
+ Base class to perform indexing for courseware or library search from different modulestores
+ """
+ __metaclass__ = ABCMeta
+
+ INDEX_NAME = None
+ DOCUMENT_TYPE = None
+ ENABLE_INDEXING_KEY = None
+
+ INDEX_EVENT = {
+ 'name': None,
+ 'category': None
+ }
+
+ @classmethod
+ def indexing_is_enabled(cls):
+ """
+ Checks to see if the indexing feature is enabled
+ """
+ return settings.FEATURES.get(cls.ENABLE_INDEXING_KEY, False)
+
+ @classmethod
+ @abstractmethod
+ def normalize_structure_key(cls, structure_key):
+ """ Normalizes structure key for use in indexing """
+
+ @classmethod
+ @abstractmethod
+ def _fetch_top_level(cls, modulestore, structure_key):
+ """ Fetch the item from the modulestore location """
+
+ @classmethod
+ @abstractmethod
+ def _get_location_info(cls, normalized_structure_key):
+ """ Builds location info dictionary """
+
+ @classmethod
+ def _id_modifier(cls, usage_id):
+ """ Modifies usage_id to submit to index """
+ return usage_id
+
+ @classmethod
+ def remove_deleted_items(cls, searcher, structure_key, exclude_items):
+ """
+ remove any item that is present in the search index that is not present in updated list of indexed items
+ as we find items we can shorten the set of items to keep
+ """
+ response = searcher.search(
+ doc_type=cls.DOCUMENT_TYPE,
+ field_dictionary=cls._get_location_info(structure_key),
+ exclude_ids=exclude_items
+ )
+ result_ids = [result["data"]["id"] for result in response["results"]]
+ for result_id in result_ids:
+ searcher.remove(cls.DOCUMENT_TYPE, result_id)
+
+ @classmethod
+ def index(cls, modulestore, structure_key, triggered_at=None, reindex_age=REINDEX_AGE):
+ """
+ Process course for indexing
+
+ Arguments:
+ structure_key (CourseKey|LibraryKey) - course or library identifier
+
+ triggered_at (datetime) - provides time at which indexing was triggered;
+ useful for index updates - only things changed recently from that date
+ (within REINDEX_AGE above ^^) will have their index updated, others skip
+ updating their index but are still walked through in order to identify
+ which items may need to be removed from the index
+ If None, then a full reindex takes place
+
+ Returns:
+ Number of items that have been added to the index
+ """
+ error_list = []
+ searcher = SearchEngine.get_search_engine(cls.INDEX_NAME)
+ if not searcher:
+ return
+
+ structure_key = cls.normalize_structure_key(structure_key)
+ location_info = cls._get_location_info(structure_key)
+
+ # Wrap counter in dictionary - otherwise we seem to lose scope inside the embedded function `index_item`
+ indexed_count = {
+ "count": 0
+ }
+
+ # indexed_items is a list of all the items that we wish to remain in the
+ # index, whether or not we are planning to actually update their index.
+ # This is used in order to build a query to remove those items not in this
+ # list - those are ready to be destroyed
+ indexed_items = set()
+
+ def index_item(item, skip_index=False):
+ """
+ Add this item to the search index and indexed_items list
+
+ Arguments:
+ item - item to add to index, its children will be processed recursively
+
+ skip_index - simply walk the children in the tree, the content change is
+ older than the REINDEX_AGE window and would have been already indexed.
+ This should really only be passed from the recursive child calls when
+ this method has determined that it is safe to do so
+ """
+ is_indexable = hasattr(item, "index_dictionary")
+ item_index_dictionary = item.index_dictionary() if is_indexable else None
+ # if it's not indexable and it does not have children, then ignore
+ if not item_index_dictionary and not item.has_children:
+ return
+
+ item_id = unicode(cls._id_modifier(item.scope_ids.usage_id))
+ indexed_items.add(item_id)
+ if item.has_children:
+ # determine if it's okay to skip adding the children herein based upon how recently any may have changed
+ skip_child_index = skip_index or \
+ (triggered_at is not None and (triggered_at - item.subtree_edited_on) > reindex_age)
+ for child_item in item.get_children():
+ index_item(child_item, skip_index=skip_child_index)
+
+ if skip_index or not item_index_dictionary:
+ return
+
+ item_index = {}
+ # if it has something to add to the index, then add it
+ try:
+ item_index.update(location_info)
+ item_index.update(item_index_dictionary)
+ item_index['id'] = item_id
+ if item.start:
+ item_index['start_date'] = item.start
+
+ searcher.index(cls.DOCUMENT_TYPE, item_index)
+ indexed_count["count"] += 1
+ except Exception as err: # pylint: disable=broad-except
+ # broad exception so that index operation does not fail on one item of many
+ log.warning('Could not index item: %s - %r', item.location, err)
+ error_list.append(_('Could not index item: {}').format(item.location))
+
+ try:
+ with modulestore.branch_setting(ModuleStoreEnum.RevisionOption.published_only):
+ structure = cls._fetch_top_level(modulestore, structure_key)
+ for item in structure.get_children():
+ index_item(item)
+ cls.remove_deleted_items(searcher, structure_key, indexed_items)
+ except Exception as err: # pylint: disable=broad-except
+ # broad exception so that index operation does not prevent the rest of the application from working
+ log.exception(
+ "Indexing error encountered, courseware index may be out of date %s - %r",
+ structure_key,
+ err
+ )
+ error_list.append(_('General indexing error occurred'))
+
+ if error_list:
+ raise SearchIndexingError('Error(s) present during indexing', error_list)
+
+ return indexed_count["count"]
+
+ @classmethod
+ def _do_reindex(cls, modulestore, structure_key):
+ """
+ (Re)index all content within the given structure (course or library),
+ tracking the fact that a full reindex has taken place
+ """
+ indexed_count = cls.index(modulestore, structure_key)
+ if indexed_count:
+ cls._track_index_request(cls.INDEX_EVENT['name'], cls.INDEX_EVENT['category'], indexed_count)
+ return indexed_count
+
+ @classmethod
+ def _track_index_request(cls, event_name, category, indexed_count):
+ """Track content index requests.
+
+ Arguments:
+ event_name (str): Name of the event to be logged.
+ category (str): category of indexed items
+ indexed_count (int): number of indexed items
+ Returns:
+ None
+
+ """
+ data = {
+ "indexed_count": indexed_count,
+ 'category': category,
+ }
+
+ tracker.emit(
+ event_name,
+ data
+ )
+
+
+class CoursewareSearchIndexer(SearchIndexerBase):
+ """
+ Class to perform indexing for courseware search from different modulestores
+ """
+ INDEX_NAME = "courseware_index"
+ DOCUMENT_TYPE = "courseware_content"
+ ENABLE_INDEXING_KEY = 'ENABLE_COURSEWARE_INDEX'
+
+ INDEX_EVENT = {
+ 'name': 'edx.course.index.reindexed',
+ 'category': 'courseware_index'
+ }
+
+ @classmethod
+ def normalize_structure_key(cls, structure_key):
+ """ Normalizes structure key for use in indexing """
+ return structure_key
+
+ @classmethod
+ def _fetch_top_level(cls, modulestore, structure_key):
+ """ Fetch the item from the modulestore location """
+ return modulestore.get_course(structure_key, depth=None)
+
+ @classmethod
+ def _get_location_info(cls, normalized_structure_key):
+ """ Builds location info dictionary """
+ return {"course": unicode(normalized_structure_key)}
+
+ @classmethod
+ def do_course_reindex(cls, modulestore, course_key):
+ """
+ (Re)index all content within the given course, tracking the fact that a full reindex has taken place
+ """
+ return cls._do_reindex(modulestore, course_key)
+
+
+class LibrarySearchIndexer(SearchIndexerBase):
+ """
+ Base class to perform indexing for library search from different modulestores
+ """
+ INDEX_NAME = "library_index"
+ DOCUMENT_TYPE = "library_content"
+ ENABLE_INDEXING_KEY = 'ENABLE_LIBRARY_INDEX'
+
+ INDEX_EVENT = {
+ 'name': 'edx.library.index.reindexed',
+ 'category': 'library_index'
+ }
+
+ @classmethod
+ def normalize_structure_key(cls, structure_key):
+ """ Normalizes structure key for use in indexing """
+ return normalize_key_for_search(structure_key)
+
+ @classmethod
+ def _fetch_top_level(cls, modulestore, structure_key):
+ """ Fetch the item from the modulestore location """
+ return modulestore.get_library(structure_key, depth=None)
+
+ @classmethod
+ def _get_location_info(cls, normalized_structure_key):
+ """ Builds location info dictionary """
+ return {"library": unicode(normalized_structure_key)}
+
+ @classmethod
+ def _id_modifier(cls, usage_id):
+ """ Modifies usage_id to submit to index """
+ return usage_id.replace(library_key=(usage_id.library_key.replace(version_guid=None, branch=None)))
+
+ @classmethod
+ def do_library_reindex(cls, modulestore, library_key):
+ """
+ (Re)index all content within the given library, tracking the fact that a full reindex has taken place
+ """
+ return cls._do_reindex(modulestore, library_key)
diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py
index 029dc01f878a..818b5b2d7740 100644
--- a/cms/djangoapps/contentstore/features/component.py
+++ b/cms/djangoapps/contentstore/features/component.py
@@ -55,9 +55,9 @@ def see_a_multi_step_component(step, category):
if category == 'HTML':
html_matcher = {
'Text': '\n \n',
- 'Announcement': '
Words of encouragement! This is a short note that most students will read.
',
+ 'Announcement': '
Announcement Date
',
'Zooming Image Tool': '
Zooming Image Tool
',
- 'E-text Written in LaTeX': '
Example: E-text page
',
+ 'E-text Written in LaTeX': '
Example: E-text page
',
'Raw HTML': '
This template is similar to the Text template. The only difference is',
}
actual_html = world.css_html(selector, index=idx)
diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py
index 6a39a29fee47..afc02aa42462 100644
--- a/cms/djangoapps/contentstore/features/course-updates.py
+++ b/cms/djangoapps/contentstore/features/course-updates.py
@@ -45,37 +45,37 @@ def check_no_update(_step, text):
@step(u'I modify the text to "([^"]*)"$')
def modify_update(_step, text):
- button_css = 'div.post-preview a.edit-button'
+ button_css = 'div.post-preview .edit-button'
world.css_click(button_css)
change_text(text)
@step(u'I change the update from "([^"]*)" to "([^"]*)"$')
def change_existing_update(_step, before, after):
- verify_text_in_editor_and_update('div.post-preview a.edit-button', before, after)
+ verify_text_in_editor_and_update('div.post-preview .edit-button', before, after)
@step(u'I change the handout from "([^"]*)" to "([^"]*)"$')
def change_existing_handout(_step, before, after):
- verify_text_in_editor_and_update('div.course-handouts a.edit-button', before, after)
+ verify_text_in_editor_and_update('div.course-handouts .edit-button', before, after)
@step(u'I delete the update$')
def click_button(_step):
- button_css = 'div.post-preview a.delete-button'
+ button_css = 'div.post-preview .delete-button'
world.css_click(button_css)
@step(u'I edit the date to "([^"]*)"$')
def change_date(_step, new_date):
- button_css = 'div.post-preview a.edit-button'
+ button_css = 'div.post-preview .edit-button'
world.css_click(button_css)
date_css = 'input.date'
date = world.css_find(date_css)
for i in range(len(date.value)):
date._element.send_keys(Keys.END, Keys.BACK_SPACE)
date._element.send_keys(new_date)
- save_css = 'a.save-button'
+ save_css = '.save-button'
world.css_click(save_css)
@@ -87,7 +87,7 @@ def check_date(_step, date):
@step(u'I modify the handout to "([^"]*)"$')
def edit_handouts(_step, text):
- edit_css = 'div.course-handouts > a.edit-button'
+ edit_css = 'div.course-handouts > .edit-button'
world.css_click(edit_css)
change_text(text)
@@ -114,7 +114,7 @@ def check_handout_error(_step):
@step(u'I see handout save button disabled')
def check_handout_error(_step):
- handout_save_button = 'form.edit-handouts-form a.save-button'
+ handout_save_button = 'form.edit-handouts-form .save-button'
assert world.css_has_class(handout_save_button, 'is-disabled')
@@ -125,19 +125,19 @@ def edit_handouts(_step, text):
@step(u'I see handout save button re-enabled')
def check_handout_error(_step):
- handout_save_button = 'form.edit-handouts-form a.save-button'
+ handout_save_button = 'form.edit-handouts-form .save-button'
assert not world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I save handout edit')
def check_handout_error(_step):
- save_css = 'a.save-button'
+ save_css = '.save-button'
world.css_click(save_css)
def change_text(text):
type_in_codemirror(0, text)
- save_css = 'a.save-button'
+ save_css = '.save-button'
world.css_click(save_css)
diff --git a/cms/djangoapps/contentstore/management/commands/reindex_library.py b/cms/djangoapps/contentstore/management/commands/reindex_library.py
new file mode 100644
index 000000000000..2c9cabc070f0
--- /dev/null
+++ b/cms/djangoapps/contentstore/management/commands/reindex_library.py
@@ -0,0 +1,75 @@
+""" Management command to update libraries' search index """
+from django.core.management import BaseCommand, CommandError
+from optparse import make_option
+from textwrap import dedent
+
+from contentstore.courseware_index import LibrarySearchIndexer
+
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from opaque_keys.edx.locator import LibraryLocator
+
+from .prompt import query_yes_no
+
+from xmodule.modulestore.django import modulestore
+
+
+class Command(BaseCommand):
+ """
+ Command to reindex content libraries (single, multiple or all available)
+
+ Examples:
+
+ ./manage.py reindex_library lib1 lib2 - reindexes libraries with keys lib1 and lib2
+ ./manage.py reindex_library --all - reindexes all available libraries
+ """
+ help = dedent(__doc__)
+
+ can_import_settings = True
+
+ args = ""
+
+ option_list = BaseCommand.option_list + (
+ make_option(
+ '--all',
+ action='store_true',
+ dest='all',
+ default=False,
+ help='Reindex all libraries'
+ ),)
+
+ CONFIRMATION_PROMPT = u"Reindexing all libraries might be a time consuming operation. Do you want to continue?"
+
+ def _parse_library_key(self, raw_value):
+ """ Parses library key from string """
+ try:
+ result = CourseKey.from_string(raw_value)
+ except InvalidKeyError:
+ result = SlashSeparatedCourseKey.from_deprecated_string(raw_value)
+
+ if not isinstance(result, LibraryLocator):
+ raise CommandError(u"Argument {0} is not a library key".format(raw_value))
+
+ return result
+
+ def handle(self, *args, **options):
+ """
+ By convention set by django developers, this method actually executes command's actions.
+ So, there could be no better docstring than emphasize this once again.
+ """
+ if len(args) == 0 and not options.get('all', False):
+ raise CommandError(u"reindex_library requires one or more arguments: ")
+
+ store = modulestore()
+
+ if options.get('all', False):
+ if query_yes_no(self.CONFIRMATION_PROMPT, default="no"):
+ library_keys = [library.location.library_key.replace(branch=None) for library in store.get_libraries()]
+ else:
+ return
+ else:
+ library_keys = map(self._parse_library_key, args)
+
+ for library_key in library_keys:
+ LibrarySearchIndexer.do_library_reindex(store, library_key)
diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py
new file mode 100644
index 000000000000..e65c7cbd1669
--- /dev/null
+++ b/cms/djangoapps/contentstore/management/commands/tests/test_reindex_library.py
@@ -0,0 +1,152 @@
+""" Tests for library reindex command """
+import sys
+import contextlib
+import ddt
+from django.core.management import call_command, CommandError
+import mock
+
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
+
+from opaque_keys import InvalidKeyError
+
+from contentstore.management.commands.reindex_library import Command as ReindexCommand
+from contentstore.courseware_index import SearchIndexingError
+
+
+@contextlib.contextmanager
+def nostderr():
+ """
+ ContextManager to suppress stderr messages
+ http://stackoverflow.com/a/1810086/882918
+ """
+ savestderr = sys.stderr
+
+ class Devnull(object):
+ """ /dev/null incarnation as output-stream-like object """
+ def write(self, _):
+ """ Write method - just does nothing"""
+ pass
+
+ sys.stderr = Devnull()
+ try:
+ yield
+ finally:
+ sys.stderr = savestderr
+
+
+@ddt.ddt
+class TestReindexLibrary(ModuleStoreTestCase):
+ """ Tests for library reindex command """
+ def setUp(self):
+ """ Setup method - create libraries and courses """
+ super(TestReindexLibrary, self).setUp()
+ self.store = modulestore()
+ self.first_lib = LibraryFactory.create(
+ org="test", library="lib1", display_name="run1", default_store=ModuleStoreEnum.Type.split
+ )
+ self.second_lib = LibraryFactory.create(
+ org="test", library="lib2", display_name="run2", default_store=ModuleStoreEnum.Type.split
+ )
+
+ self.first_course = CourseFactory.create(
+ org="test", course="course1", display_name="run1", default_store=ModuleStoreEnum.Type.split
+ )
+ self.second_course = CourseFactory.create(
+ org="test", course="course2", display_name="run1", default_store=ModuleStoreEnum.Type.split
+ )
+
+ REINDEX_PATH_LOCATION = 'contentstore.management.commands.reindex_library.LibrarySearchIndexer.do_library_reindex'
+ MODULESTORE_PATCH_LOCATION = 'contentstore.management.commands.reindex_library.modulestore'
+ YESNO_PATCH_LOCATION = 'contentstore.management.commands.reindex_library.query_yes_no'
+
+ def _get_lib_key(self, library):
+ """ Get's library key as it is passed to indexer """
+ return library.location.library_key
+
+ def _build_calls(self, *libraries):
+ """ BUilds a list of mock.call instances representing calls to reindexing method """
+ return [mock.call(self.store, self._get_lib_key(lib)) for lib in libraries]
+
+ def test_given_no_arguments_raises_command_error(self):
+ """ Test that raises CommandError for incorrect arguments """
+ with self.assertRaises(SystemExit), nostderr():
+ with self.assertRaisesRegexp(CommandError, ".* requires one or more arguments .*"):
+ call_command('reindex_library')
+
+ @ddt.data('qwerty', 'invalid_key', 'xblock-v1:qwe+rty')
+ def test_given_invalid_lib_key_raises_not_found(self, invalid_key):
+ """ Test that raises InvalidKeyError for invalid keys """
+ with self.assertRaises(InvalidKeyError):
+ call_command('reindex_library', invalid_key)
+
+ def test_given_course_key_raises_command_error(self):
+ """ Test that raises CommandError if course key is passed """
+ with self.assertRaises(SystemExit), nostderr():
+ with self.assertRaisesRegexp(CommandError, ".* is not a library key"):
+ call_command('reindex_library', unicode(self.first_course.id))
+
+ with self.assertRaises(SystemExit), nostderr():
+ with self.assertRaisesRegexp(CommandError, ".* is not a library key"):
+ call_command('reindex_library', unicode(self.second_course.id))
+
+ with self.assertRaises(SystemExit), nostderr():
+ with self.assertRaisesRegexp(CommandError, ".* is not a library key"):
+ call_command(
+ 'reindex_library',
+ unicode(self.second_course.id),
+ unicode(self._get_lib_key(self.first_lib))
+ )
+
+ def test_given_id_list_indexes_libraries(self):
+ """ Test that reindexes libraries when given single library key or a list of library keys """
+ with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
+ mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
+ call_command('reindex_library', unicode(self._get_lib_key(self.first_lib)))
+ self.assertEqual(patched_index.mock_calls, self._build_calls(self.first_lib))
+ patched_index.reset_mock()
+
+ call_command('reindex_library', unicode(self._get_lib_key(self.second_lib)))
+ self.assertEqual(patched_index.mock_calls, self._build_calls(self.second_lib))
+ patched_index.reset_mock()
+
+ call_command(
+ 'reindex_library',
+ unicode(self._get_lib_key(self.first_lib)),
+ unicode(self._get_lib_key(self.second_lib))
+ )
+ expected_calls = self._build_calls(self.first_lib, self.second_lib)
+ self.assertEqual(patched_index.mock_calls, expected_calls)
+
+ def test_given_all_key_prompts_and_reindexes_all_libraries(self):
+ """ Test that reindexes all libraries when --all key is given and confirmed """
+ with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
+ patched_yes_no.return_value = True
+ with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
+ mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
+ call_command('reindex_library', all=True)
+
+ patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
+ expected_calls = self._build_calls(self.first_lib, self.second_lib)
+ self.assertEqual(patched_index.mock_calls, expected_calls)
+
+ def test_given_all_key_prompts_and_reindexes_all_libraries_cancelled(self):
+ """ Test that does not reindex anything when --all key is given and cancelled """
+ with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
+ patched_yes_no.return_value = False
+ with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
+ mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
+ call_command('reindex_library', all=True)
+
+ patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
+ patched_index.assert_not_called()
+
+ def test_fail_fast_if_reindex_fails(self):
+ """ Test that fails on first reindexing exception """
+ with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index:
+ patched_index.side_effect = SearchIndexingError("message", [])
+
+ with self.assertRaises(SearchIndexingError):
+ call_command('reindex_library', unicode(self._get_lib_key(self.second_lib)))
diff --git a/cms/djangoapps/contentstore/migrations/0003_auto__add_pushnotificationconfig.py b/cms/djangoapps/contentstore/migrations/0003_auto__add_pushnotificationconfig.py
new file mode 100644
index 000000000000..7963916271e3
--- /dev/null
+++ b/cms/djangoapps/contentstore/migrations/0003_auto__add_pushnotificationconfig.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding model 'PushNotificationConfig'
+ db.create_table('contentstore_pushnotificationconfig', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
+ ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
+ ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
+ ))
+ db.send_create_signal('contentstore', ['PushNotificationConfig'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'PushNotificationConfig'
+ db.delete_table('contentstore_pushnotificationconfig')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contentstore.pushnotificationconfig': {
+ 'Meta': {'object_name': 'PushNotificationConfig'},
+ 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
+ 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'contentstore.videouploadconfig': {
+ 'Meta': {'object_name': 'VideoUploadConfig'},
+ 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
+ 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'profile_whitelist': ('django.db.models.fields.TextField', [], {'blank': 'True'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ }
+ }
+
+ complete_apps = ['contentstore']
\ No newline at end of file
diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py
index d2a79738923d..d2112bd9f1e7 100644
--- a/cms/djangoapps/contentstore/models.py
+++ b/cms/djangoapps/contentstore/models.py
@@ -19,3 +19,7 @@ class VideoUploadConfig(ConfigurationModel):
def get_profile_whitelist(cls):
"""Get the list of profiles to include in the encoding download"""
return [profile for profile in cls.current().profile_whitelist.split(",") if profile]
+
+
+class PushNotificationConfig(ConfigurationModel):
+ """Configuration for mobile push notifications."""
diff --git a/cms/djangoapps/contentstore/push_notification.py b/cms/djangoapps/contentstore/push_notification.py
new file mode 100644
index 000000000000..ec99d6171310
--- /dev/null
+++ b/cms/djangoapps/contentstore/push_notification.py
@@ -0,0 +1,82 @@
+"""
+Helper methods for push notifications from Studio.
+"""
+
+from uuid import uuid4
+from django.conf import settings
+from logging import exception as log_exception
+
+from contentstore.tasks import push_course_update_task
+from contentstore.models import PushNotificationConfig
+from xmodule.modulestore.django import modulestore
+from parse_rest.installation import Push
+from parse_rest.connection import register
+from parse_rest.core import ParseError
+
+
+def push_notification_enabled():
+ """
+ Returns whether the push notification feature is enabled.
+ """
+ return PushNotificationConfig.is_enabled()
+
+
+def enqueue_push_course_update(update, course_key):
+ """
+ Enqueues a task for push notification for the given update for the given course if
+ (1) the feature is enabled and
+ (2) push_notification is selected for the update
+ """
+ if push_notification_enabled() and update.get("push_notification_selected"):
+ course = modulestore().get_course(course_key)
+ if course:
+ push_course_update_task.delay(
+ unicode(course_key),
+ course.clean_id(padding_char='_'),
+ course.display_name
+ )
+
+
+def send_push_course_update(course_key_string, course_subscription_id, course_display_name):
+ """
+ Sends a push notification for a course update, given the course's subscription_id and display_name.
+ """
+ if settings.PARSE_KEYS:
+ try:
+ register(
+ settings.PARSE_KEYS["APPLICATION_ID"],
+ settings.PARSE_KEYS["REST_API_KEY"],
+ )
+ push_payload = {
+ "action": "course.announcement",
+ "notification-id": unicode(uuid4()),
+
+ "course-id": course_key_string,
+ "course-name": course_display_name,
+ }
+ push_channels = [course_subscription_id]
+
+ # Push to all Android devices
+ Push.alert(
+ data=push_payload,
+ channels={"$in": push_channels},
+ where={"deviceType": "android"},
+ )
+
+ # Push to all iOS devices
+ # With additional payload so that
+ # 1. The push is displayed automatically
+ # 2. The app gets it even in the background.
+ # See http://stackoverflow.com/questions/19239737/silent-push-notification-in-ios-7-does-not-work
+ push_payload.update({
+ "alert": "",
+ "content-available": 1
+ })
+ Push.alert(
+ data=push_payload,
+ channels={"$in": push_channels},
+ where={"deviceType": "ios"},
+ )
+
+ except ParseError as error:
+ log_exception(error.message)
diff --git a/cms/djangoapps/contentstore/signals.py b/cms/djangoapps/contentstore/signals.py
new file mode 100644
index 000000000000..8ada95bce1e1
--- /dev/null
+++ b/cms/djangoapps/contentstore/signals.py
@@ -0,0 +1,30 @@
+""" receivers of course_published and library_updated events in order to trigger indexing task """
+from datetime import datetime
+from pytz import UTC
+
+from django.dispatch import receiver
+
+from xmodule.modulestore.django import SignalHandler
+from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer
+
+
+@receiver(SignalHandler.course_published)
+def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
+ """
+ Receives signal and kicks off celery task to update search index
+ """
+ # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
+ from .tasks import update_search_index
+ if CoursewareSearchIndexer.indexing_is_enabled():
+ update_search_index.delay(unicode(course_key), datetime.now(UTC).isoformat())
+
+
+@receiver(SignalHandler.library_updated)
+def listen_for_library_update(sender, library_key, **kwargs): # pylint: disable=unused-argument
+ """
+ Receives signal and kicks off celery task to update search index
+ """
+ # import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
+ from .tasks import update_library_index
+ if LibrarySearchIndexer.indexing_is_enabled():
+ update_library_index.delay(unicode(library_key), datetime.now(UTC).isoformat())
diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py
index 222ad7a44de7..b67600e2385d 100644
--- a/cms/djangoapps/contentstore/tasks.py
+++ b/cms/djangoapps/contentstore/tasks.py
@@ -1,20 +1,25 @@
"""
This file contains celery tasks for contentstore views
"""
-
-from celery.task import task
-from django.contrib.auth.models import User
import json
import logging
-from xmodule.modulestore.django import modulestore
-from xmodule.course_module import CourseFields
+from celery.task import task
+from celery.utils.log import get_task_logger
+from datetime import datetime
+from pytz import UTC
-from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError
-from course_action_state.models import CourseRerunState
+from django.contrib.auth.models import User
+
+from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer, SearchIndexingError
from contentstore.utils import initialize_permissions
+from course_action_state.models import CourseRerunState
from opaque_keys.edx.keys import CourseKey
+from xmodule.course_module import CourseFields
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError
-from edxval.api import copy_course_videos
+LOGGER = get_task_logger(__name__)
+FULL_COURSE_REINDEX_THRESHOLD = 1
@task()
@@ -22,6 +27,9 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i
"""
Reruns a course in a new celery task.
"""
+ # import here, at top level this import prevents the celery workers from starting up correctly
+ from edxval.api import copy_course_videos
+
try:
# deserialize the payload
source_course_key = CourseKey.from_string(source_course_key_string)
@@ -72,3 +80,48 @@ def deserialize_fields(json_fields):
for field_name, value in fields.iteritems():
fields[field_name] = getattr(CourseFields, field_name).from_json(value)
return fields
+
+
+def _parse_time(time_isoformat):
+ """ Parses time from iso format """
+ return datetime.strptime(
+ # remove the +00:00 from the end of the formats generated within the system
+ time_isoformat.split('+')[0],
+ "%Y-%m-%dT%H:%M:%S.%f"
+ ).replace(tzinfo=UTC)
+
+
+@task()
+def update_search_index(course_id, triggered_time_isoformat):
+ """ Updates course search index. """
+ try:
+ course_key = CourseKey.from_string(course_id)
+ CoursewareSearchIndexer.index(modulestore(), course_key, triggered_at=(_parse_time(triggered_time_isoformat)))
+
+ except SearchIndexingError as exc:
+ LOGGER.error('Search indexing error for complete course %s - %s', course_id, unicode(exc))
+ else:
+ LOGGER.debug('Search indexing successful for complete course %s', course_id)
+
+
+@task()
+def update_library_index(library_id, triggered_time_isoformat):
+ """ Updates course search index. """
+ try:
+ library_key = CourseKey.from_string(library_id)
+ LibrarySearchIndexer.index(modulestore(), library_key, triggered_at=(_parse_time(triggered_time_isoformat)))
+
+ except SearchIndexingError as exc:
+ LOGGER.error('Search indexing error for library %s - %s', library_id, unicode(exc))
+ else:
+ LOGGER.debug('Search indexing successful for library %s', library_id)
+
+
+@task()
+def push_course_update_task(course_key_string, course_subscription_id, course_display_name):
+ """
+ Sends a push notification for a course update.
+ """
+ # TODO Use edx-notifications library instead (MA-638).
+ from .push_notification import send_push_course_update
+ send_push_course_update(course_key_string, course_subscription_id, course_display_name)
diff --git a/cms/djangoapps/contentstore/tests/test_courseware_index.py b/cms/djangoapps/contentstore/tests/test_courseware_index.py
new file mode 100644
index 000000000000..57117fe17635
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_courseware_index.py
@@ -0,0 +1,785 @@
+"""
+Testing indexing of the courseware as it is changed
+"""
+import ddt
+from lazy.lazy import lazy
+import time
+from datetime import datetime
+from mock import patch
+from pytz import UTC
+from uuid import uuid4
+from unittest import skip
+
+from xmodule.library_tools import normalize_key_for_search
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import SignalHandler
+from xmodule.modulestore.edit_info import EditInfoMixin
+from xmodule.modulestore.inheritance import InheritanceMixin
+from xmodule.modulestore.mixed import MixedModuleStore
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory
+from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
+from xmodule.modulestore.tests.test_cross_modulestore_import_export import MongoContentstoreBuilder
+from xmodule.modulestore.tests.utils import create_modulestore_instance, LocationMixin, MixedSplitTestCase
+from xmodule.tests import DATA_DIR
+from xmodule.x_module import XModuleMixin
+
+from search.search_engine_base import SearchEngine
+
+from contentstore.courseware_index import CoursewareSearchIndexer, LibrarySearchIndexer, SearchIndexingError
+from contentstore.signals import listen_for_course_publish, listen_for_library_update
+
+
+COURSE_CHILD_STRUCTURE = {
+ "course": "chapter",
+ "chapter": "sequential",
+ "sequential": "vertical",
+ "vertical": "html",
+}
+
+
+def create_children(store, parent, category, load_factor):
+ """ create load_factor children within the given parent; recursively call to insert children when appropriate """
+ created_count = 0
+ for child_index in range(0, load_factor):
+ child_object = ItemFactory.create(
+ parent_location=parent.location,
+ category=category,
+ display_name=u"{} {} {}".format(category, child_index, time.clock()),
+ modulestore=store,
+ publish_item=True,
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ created_count += 1
+
+ if category in COURSE_CHILD_STRUCTURE:
+ created_count += create_children(store, child_object, COURSE_CHILD_STRUCTURE[category], load_factor)
+
+ return created_count
+
+
+def create_large_course(store, load_factor):
+ """
+ Create a large course, note that the number of blocks created will be
+ load_factor ^ 4 - e.g. load_factor of 10 => 10 chapters, 100
+ sequentials, 1000 verticals, 10000 html blocks
+ """
+ course = CourseFactory.create(modulestore=store, start=datetime(2015, 3, 1, tzinfo=UTC))
+ with store.bulk_operations(course.id):
+ child_count = create_children(store, course, COURSE_CHILD_STRUCTURE["course"], load_factor)
+ return course, child_count
+
+
+class MixedWithOptionsTestCase(MixedSplitTestCase):
+ """ Base class for test cases within this file """
+ HOST = MONGO_HOST
+ PORT = MONGO_PORT_NUM
+ DATABASE = 'test_mongo_%s' % uuid4().hex[:5]
+ COLLECTION = 'modulestore'
+ ASSET_COLLECTION = 'assetstore'
+ DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
+ RENDER_TEMPLATE = lambda t_n, d, ctx=None, nsp='main': ''
+ modulestore_options = {
+ 'default_class': DEFAULT_CLASS,
+ 'fs_root': DATA_DIR,
+ 'render_template': RENDER_TEMPLATE,
+ 'xblock_mixins': (EditInfoMixin, InheritanceMixin, LocationMixin, XModuleMixin),
+ }
+ DOC_STORE_CONFIG = {
+ 'host': HOST,
+ 'port': PORT,
+ 'db': DATABASE,
+ 'collection': COLLECTION,
+ 'asset_collection': ASSET_COLLECTION,
+ }
+ OPTIONS = {
+ 'stores': [
+ {
+ 'NAME': 'draft',
+ 'ENGINE': 'xmodule.modulestore.mongo.draft.DraftModuleStore',
+ 'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
+ 'OPTIONS': modulestore_options
+ },
+ {
+ 'NAME': 'split',
+ 'ENGINE': 'xmodule.modulestore.split_mongo.split_draft.DraftVersioningModuleStore',
+ 'DOC_STORE_CONFIG': DOC_STORE_CONFIG,
+ 'OPTIONS': modulestore_options
+ },
+ {
+ 'NAME': 'xml',
+ 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
+ 'OPTIONS': {
+ 'data_dir': DATA_DIR,
+ 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
+ 'xblock_mixins': modulestore_options['xblock_mixins'],
+ }
+ },
+ ],
+ 'xblock_mixins': modulestore_options['xblock_mixins'],
+ }
+
+ INDEX_NAME = None
+
+ def setUp(self):
+ super(MixedWithOptionsTestCase, self).setUp()
+
+ def setup_course_base(self, store):
+ """ base version of setup_course_base is a no-op """
+ pass
+
+ @lazy
+ def searcher(self):
+ """ Centralized call to getting the search engine for the test """
+ return SearchEngine.get_search_engine(self.INDEX_NAME)
+
+ def _get_default_search(self):
+ """ Returns field_dictionary for default search """
+ return {}
+
+ def search(self, field_dictionary=None):
+ """ Performs index search according to passed parameters """
+ fields = field_dictionary if field_dictionary else self._get_default_search()
+ return self.searcher.search(field_dictionary=fields)
+
+ def _perform_test_using_store(self, store_type, test_to_perform):
+ """ Helper method to run a test function that uses a specific store """
+ with MongoContentstoreBuilder().build() as contentstore:
+ store = MixedModuleStore(
+ contentstore=contentstore,
+ create_modulestore_instance=create_modulestore_instance,
+ mappings={},
+ **self.OPTIONS
+ )
+ self.addCleanup(store.close_all_connections)
+
+ with store.default_store(store_type):
+ self.setup_course_base(store)
+ test_to_perform(store)
+
+ def publish_item(self, store, item_location):
+ """ publish the item at the given location """
+ with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
+ store.publish(item_location, ModuleStoreEnum.UserID.test)
+
+ def delete_item(self, store, item_location):
+ """ delete the item at the given location """
+ with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
+ store.delete_item(item_location, ModuleStoreEnum.UserID.test)
+
+ def update_item(self, store, item):
+ """ update the item at the given location """
+ with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
+ store.update_item(item, ModuleStoreEnum.UserID.test)
+
+
+@ddt.ddt
+class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
+ """ Tests the operation of the CoursewareSearchIndexer """
+
+ WORKS_WITH_STORES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+
+ def setUp(self):
+ super(TestCoursewareSearchIndexer, self).setUp()
+
+ self.course = None
+ self.chapter = None
+ self.sequential = None
+ self.vertical = None
+ self.html_unit = None
+
+ def setup_course_base(self, store):
+ """
+ Set up the for the course outline tests.
+ """
+ self.course = CourseFactory.create(modulestore=store, start=datetime(2015, 3, 1, tzinfo=UTC))
+
+ self.chapter = ItemFactory.create(
+ parent_location=self.course.location,
+ category='chapter',
+ display_name="Week 1",
+ modulestore=store,
+ publish_item=True,
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ self.sequential = ItemFactory.create(
+ parent_location=self.chapter.location,
+ category='sequential',
+ display_name="Lesson 1",
+ modulestore=store,
+ publish_item=True,
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ self.vertical = ItemFactory.create(
+ parent_location=self.sequential.location,
+ category='vertical',
+ display_name='Subsection 1',
+ modulestore=store,
+ publish_item=True,
+ start=datetime(2015, 4, 1, tzinfo=UTC),
+ )
+ # unspecified start - should inherit from container
+ self.html_unit = ItemFactory.create(
+ parent_location=self.vertical.location,
+ category="html",
+ display_name="Html Content",
+ modulestore=store,
+ publish_item=False,
+ )
+
+ INDEX_NAME = CoursewareSearchIndexer.INDEX_NAME
+
+ def reindex_course(self, store):
+ """ kick off complete reindex of the course """
+ return CoursewareSearchIndexer.do_course_reindex(store, self.course.id)
+
+ def index_recent_changes(self, store, since_time):
+ """ index course using recent changes """
+ trigger_time = datetime.now(UTC)
+ return CoursewareSearchIndexer.index(
+ store,
+ self.course.id,
+ triggered_at=trigger_time,
+ reindex_age=(trigger_time - since_time)
+ )
+
+ def _get_default_search(self):
+ return {"course": unicode(self.course.id)}
+
+ def _test_indexing_course(self, store):
+ """ indexing course tests """
+ response = self.search()
+ self.assertEqual(response["total"], 0)
+
+ # Only published modules should be in the index
+ added_to_index = self.reindex_course(store)
+ self.assertEqual(added_to_index, 3)
+ response = self.search()
+ self.assertEqual(response["total"], 3)
+
+ # Publish the vertical as is, and any unpublished children should now be available
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ def _test_not_indexing_unpublished_content(self, store):
+ """ add a new one, only appers in index once added """
+ # Publish the vertical to start with
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ # Now add a new unit to the existing vertical
+ ItemFactory.create(
+ parent_location=self.vertical.location,
+ category="html",
+ display_name="Some other content",
+ publish_item=False,
+ modulestore=store,
+ )
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ # Now publish it and we should find it
+ # Publish the vertical as is, and everything should be available
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 5)
+
+ def _test_deleting_item(self, store):
+ """ test deleting an item """
+ # Publish the vertical to start with
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ # just a delete should not change anything
+ self.delete_item(store, self.html_unit.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ # but after publishing, we should no longer find the html_unit
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 3)
+
+ def _test_not_indexable(self, store):
+ """ test not indexable items """
+ # Publish the vertical to start with
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ # Add a non-indexable item
+ ItemFactory.create(
+ parent_location=self.vertical.location,
+ category="openassessment",
+ display_name="Some other content",
+ publish_item=False,
+ modulestore=store,
+ )
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ # even after publishing, we should not find the non-indexable item
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ def _test_start_date_propagation(self, store):
+ """ make sure that the start date is applied at the right level """
+ early_date = self.course.start
+ later_date = self.vertical.start
+
+ # Publish the vertical
+ self.publish_item(store, self.vertical.location)
+ self.reindex_course(store)
+ response = self.search()
+ self.assertEqual(response["total"], 4)
+
+ results = response["results"]
+ date_map = {
+ unicode(self.chapter.location): early_date,
+ unicode(self.sequential.location): early_date,
+ unicode(self.vertical.location): later_date,
+ unicode(self.html_unit.location): later_date,
+ }
+ for result in results:
+ self.assertEqual(result["data"]["start_date"], date_map[result["data"]["id"]])
+
+ @patch('django.conf.settings.SEARCH_ENGINE', None)
+ def _test_search_disabled(self, store):
+ """ if search setting has it as off, confirm that nothing is indexed """
+ indexed_count = self.reindex_course(store)
+ self.assertFalse(indexed_count)
+
+ def _test_time_based_index(self, store):
+ """ Make sure that a time based request to index does not index anything too old """
+ self.publish_item(store, self.vertical.location)
+ indexed_count = self.reindex_course(store)
+ self.assertEqual(indexed_count, 4)
+
+ # Add a new sequential
+ sequential2 = ItemFactory.create(
+ parent_location=self.chapter.location,
+ category='sequential',
+ display_name='Section 2',
+ modulestore=store,
+ publish_item=True,
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+
+ # add a new vertical
+ vertical2 = ItemFactory.create(
+ parent_location=sequential2.location,
+ category='vertical',
+ display_name='Subsection 2',
+ modulestore=store,
+ publish_item=True,
+ )
+ ItemFactory.create(
+ parent_location=vertical2.location,
+ category="html",
+ display_name="Some other content",
+ publish_item=False,
+ modulestore=store,
+ )
+
+ before_time = datetime.now(UTC)
+ self.publish_item(store, vertical2.location)
+ # index based on time, will include an index of the origin sequential
+ # because it is in a common subtree but not of the original vertical
+ # because the original sequential's subtree is too old
+ new_indexed_count = self.index_recent_changes(store, before_time)
+ self.assertEqual(new_indexed_count, 5)
+
+ # full index again
+ indexed_count = self.reindex_course(store)
+ self.assertEqual(indexed_count, 7)
+
+ @patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ErroringIndexEngine')
+ def _test_exception(self, store):
+ """ Test that exception within indexing yields a SearchIndexingError """
+ self.publish_item(store, self.vertical.location)
+ with self.assertRaises(SearchIndexingError):
+ self.reindex_course(store)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_indexing_course(self, store_type):
+ self._perform_test_using_store(store_type, self._test_indexing_course)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_not_indexing_unpublished_content(self, store_type):
+ self._perform_test_using_store(store_type, self._test_not_indexing_unpublished_content)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_deleting_item(self, store_type):
+ self._perform_test_using_store(store_type, self._test_deleting_item)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_not_indexable(self, store_type):
+ self._perform_test_using_store(store_type, self._test_not_indexable)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_start_date_propagation(self, store_type):
+ self._perform_test_using_store(store_type, self._test_start_date_propagation)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_search_disabled(self, store_type):
+ self._perform_test_using_store(store_type, self._test_search_disabled)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_time_based_index(self, store_type):
+ self._perform_test_using_store(store_type, self._test_time_based_index)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_exception(self, store_type):
+ self._perform_test_using_store(store_type, self._test_exception)
+
+
+@patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ForceRefreshElasticSearchEngine')
+@ddt.ddt
+class TestLargeCourseDeletions(MixedWithOptionsTestCase):
+ """ Tests to excerise deleting items from a course """
+
+ WORKS_WITH_STORES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+
+ def _clean_course_id(self):
+ """ Clean all documents from the index that have a specific course provided """
+ if self.course_id:
+
+ response = self.searcher.search(field_dictionary={"course": self.course_id})
+ while response["total"] > 0:
+ for item in response["results"]:
+ self.searcher.remove(CoursewareSearchIndexer.DOCUMENT_TYPE, item["data"]["id"])
+ self.searcher.remove(CoursewareSearchIndexer.DOCUMENT_TYPE, item["data"]["id"])
+ response = self.searcher.search(field_dictionary={"course": self.course_id})
+ self.course_id = None
+
+ def setUp(self):
+ super(TestLargeCourseDeletions, self).setUp()
+ self.course_id = None
+
+ def tearDown(self):
+ super(TestLargeCourseDeletions, self).tearDown()
+ self._clean_course_id()
+
+ def assert_search_count(self, expected_count):
+ """ Check that the search within this course will yield the expected number of results """
+
+ response = self.searcher.search(field_dictionary={"course": self.course_id})
+ self.assertEqual(response["total"], expected_count)
+
+ def _do_test_large_course_deletion(self, store, load_factor):
+ """ Test that deleting items from a course works even when present within a very large course """
+ def id_list(top_parent_object):
+ """ private function to get ids from object down the tree """
+ list_of_ids = [unicode(top_parent_object.location)]
+ for child in top_parent_object.get_children():
+ list_of_ids.extend(id_list(child))
+ return list_of_ids
+
+ course, course_size = create_large_course(store, load_factor)
+ self.course_id = unicode(course.id)
+
+ # index full course
+ CoursewareSearchIndexer.do_course_reindex(store, course.id)
+
+ self.assert_search_count(course_size)
+
+ # reload course to allow us to delete one single unit
+ course = store.get_course(course.id, depth=1)
+
+ # delete the first chapter
+ chapter_to_delete = course.get_children()[0]
+ self.delete_item(store, chapter_to_delete.location)
+
+ # index and check correctness
+ CoursewareSearchIndexer.do_course_reindex(store, course.id)
+ deleted_count = 1 + load_factor + (load_factor ** 2) + (load_factor ** 3)
+ self.assert_search_count(course_size - deleted_count)
+
+ def _test_large_course_deletion(self, store):
+ """ exception catch-ing wrapper around large test course test with deletions """
+ # load_factor of 6 (1296 items) takes about 5 minutes to run on devstack on a laptop
+ # load_factor of 7 (2401 items) takes about 70 minutes to run on devstack on a laptop
+ # load_factor of 8 (4096 items) takes just under 3 hours to run on devstack on a laptop
+ load_factor = 6
+ try:
+ self._do_test_large_course_deletion(store, load_factor)
+ except: # pylint: disable=bare-except
+ # Catch any exception here to see when we fail
+ print "Failed with load_factor of {}".format(load_factor)
+
+ @skip(("This test is to see how we handle very large courses, to ensure that the delete"
+ "procedure works smoothly - too long to run during the normal course of things"))
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_large_course_deletion(self, store_type):
+ self._perform_test_using_store(store_type, self._test_large_course_deletion)
+
+
+class TestTaskExecution(ModuleStoreTestCase):
+ """
+ Set of tests to ensure that the task code will do the right thing when
+ executed directly. The test course and library gets created without the listeners
+ being present, which allows us to ensure that when the listener is
+ executed, it is done as expected.
+ """
+
+ def setUp(self):
+ super(TestTaskExecution, self).setUp()
+ SignalHandler.course_published.disconnect(listen_for_course_publish)
+ SignalHandler.library_updated.disconnect(listen_for_library_update)
+ self.course = CourseFactory.create(start=datetime(2015, 3, 1, tzinfo=UTC))
+
+ self.chapter = ItemFactory.create(
+ parent_location=self.course.location,
+ category='chapter',
+ display_name="Week 1",
+ publish_item=True,
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ self.sequential = ItemFactory.create(
+ parent_location=self.chapter.location,
+ category='sequential',
+ display_name="Lesson 1",
+ publish_item=True,
+ start=datetime(2015, 3, 1, tzinfo=UTC),
+ )
+ self.vertical = ItemFactory.create(
+ parent_location=self.sequential.location,
+ category='vertical',
+ display_name='Subsection 1',
+ publish_item=True,
+ start=datetime(2015, 4, 1, tzinfo=UTC),
+ )
+ # unspecified start - should inherit from container
+ self.html_unit = ItemFactory.create(
+ parent_location=self.vertical.location,
+ category="html",
+ display_name="Html Content",
+ publish_item=False,
+ )
+
+ self.library = LibraryFactory.create()
+
+ self.library_block1 = ItemFactory.create(
+ parent_location=self.library.location,
+ category="html",
+ display_name="Html Content",
+ publish_item=False,
+ )
+
+ self.library_block2 = ItemFactory.create(
+ parent_location=self.library.location,
+ category="html",
+ display_name="Html Content 2",
+ publish_item=False,
+ )
+
+ def test_task_indexing_course(self):
+ """ Making sure that the receiver correctly fires off the task when invoked by signal """
+ searcher = SearchEngine.get_search_engine(CoursewareSearchIndexer.INDEX_NAME)
+ response = searcher.search(field_dictionary={"course": unicode(self.course.id)})
+ self.assertEqual(response["total"], 0)
+
+ listen_for_course_publish(self, self.course.id)
+
+ # Note that this test will only succeed if celery is working in inline mode
+ response = searcher.search(field_dictionary={"course": unicode(self.course.id)})
+ self.assertEqual(response["total"], 3)
+
+ def test_task_library_update(self):
+ """ Making sure that the receiver correctly fires off the task when invoked by signal """
+ searcher = SearchEngine.get_search_engine(LibrarySearchIndexer.INDEX_NAME)
+ library_search_key = unicode(normalize_key_for_search(self.library.location.library_key))
+ response = searcher.search(field_dictionary={"library": library_search_key})
+ self.assertEqual(response["total"], 0)
+
+ listen_for_library_update(self, self.library.location.library_key)
+
+ # Note that this test will only succeed if celery is working in inline mode
+ response = searcher.search(field_dictionary={"library": library_search_key})
+ self.assertEqual(response["total"], 2)
+
+
+@ddt.ddt
+class TestLibrarySearchIndexer(MixedWithOptionsTestCase):
+ """ Tests the operation of the CoursewareSearchIndexer """
+
+ # libraries work only with split, so do library indexer
+ WORKS_WITH_STORES = (ModuleStoreEnum.Type.split, )
+
+ def setUp(self):
+ super(TestLibrarySearchIndexer, self).setUp()
+
+ self.library = None
+ self.html_unit1 = None
+ self.html_unit2 = None
+
+ def setup_course_base(self, store):
+ """
+ Set up the for the course outline tests.
+ """
+ self.library = LibraryFactory.create(modulestore=store)
+
+ self.html_unit1 = ItemFactory.create(
+ parent_location=self.library.location,
+ category="html",
+ display_name="Html Content",
+ modulestore=store,
+ publish_item=False,
+ )
+
+ self.html_unit2 = ItemFactory.create(
+ parent_location=self.library.location,
+ category="html",
+ display_name="Html Content 2",
+ modulestore=store,
+ publish_item=False,
+ )
+
+ INDEX_NAME = LibrarySearchIndexer.INDEX_NAME
+
+ def _get_default_search(self):
+ """ Returns field_dictionary for default search """
+ return {"library": unicode(self.library.location.library_key.replace(version_guid=None, branch=None))}
+
+ def reindex_library(self, store):
+ """ kick off complete reindex of the course """
+ return LibrarySearchIndexer.do_library_reindex(store, self.library.location.library_key)
+
+ def _get_contents(self, response):
+ """ Extracts contents from search response """
+ return [item['data']['content'] for item in response['results']]
+
+ def _test_indexing_library(self, store):
+ """ indexing course tests """
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ added_to_index = self.reindex_library(store)
+ self.assertEqual(added_to_index, 2)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ def _test_creating_item(self, store):
+ """ test updating an item """
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ # updating a library item causes immediate reindexing
+ data = "Some data"
+ ItemFactory.create(
+ parent_location=self.library.location,
+ category="html",
+ display_name="Html Content 3",
+ data=data,
+ modulestore=store,
+ publish_item=False,
+ )
+
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 3)
+ html_contents = [cont['html_content'] for cont in self._get_contents(response)]
+ self.assertIn(data, html_contents)
+
+ def _test_updating_item(self, store):
+ """ test updating an item """
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ # updating a library item causes immediate reindexing
+ new_data = "I'm new data"
+ self.html_unit1.data = new_data
+ self.update_item(store, self.html_unit1)
+ self.reindex_library(store)
+ response = self.search()
+ # TODO: MockSearchEngine never updates existing item: returns 3 items here - uncomment when it's fixed
+ # self.assertEqual(response["total"], 2)
+ html_contents = [cont['html_content'] for cont in self._get_contents(response)]
+ self.assertIn(new_data, html_contents)
+
+ def _test_deleting_item(self, store):
+ """ test deleting an item """
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ # deleting a library item causes immediate reindexing
+ self.delete_item(store, self.html_unit1.location)
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 1)
+
+ def _test_not_indexable(self, store):
+ """ test not indexable items """
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ # Add a non-indexable item
+ ItemFactory.create(
+ parent_location=self.library.location,
+ category="openassessment",
+ display_name="Assessment",
+ publish_item=False,
+ modulestore=store,
+ )
+ self.reindex_library(store)
+ response = self.search()
+ self.assertEqual(response["total"], 2)
+
+ @patch('django.conf.settings.SEARCH_ENGINE', None)
+ def _test_search_disabled(self, store):
+ """ if search setting has it as off, confirm that nothing is indexed """
+ indexed_count = self.reindex_library(store)
+ self.assertFalse(indexed_count)
+
+ @patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ErroringIndexEngine')
+ def _test_exception(self, store):
+ """ Test that exception within indexing yields a SearchIndexingError """
+ with self.assertRaises(SearchIndexingError):
+ self.reindex_library(store)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_indexing_library(self, store_type):
+ self._perform_test_using_store(store_type, self._test_indexing_library)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_updating_item(self, store_type):
+ self._perform_test_using_store(store_type, self._test_updating_item)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_creating_item(self, store_type):
+ self._perform_test_using_store(store_type, self._test_creating_item)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_deleting_item(self, store_type):
+ self._perform_test_using_store(store_type, self._test_deleting_item)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_not_indexable(self, store_type):
+ self._perform_test_using_store(store_type, self._test_not_indexable)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_search_disabled(self, store_type):
+ self._perform_test_using_store(store_type, self._test_search_disabled)
+
+ @ddt.data(*WORKS_WITH_STORES)
+ def test_exception(self, store_type):
+ self._perform_test_using_store(store_type, self._test_exception)
diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py
index 4efb1cb8ec4a..f69f450493a7 100644
--- a/cms/djangoapps/contentstore/tests/test_import.py
+++ b/cms/djangoapps/contentstore/tests/test_import.py
@@ -28,7 +28,7 @@
@ddt.ddt
-@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
+@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, SEARCH_ENGINE=None)
class ContentStoreImportTest(SignalDisconnectTestMixin, ModuleStoreTestCase):
"""
Tests that rely on the toy and test_import_course courses.
diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py
index 8a83dfd7cc0f..b9a71e9a5f01 100644
--- a/cms/djangoapps/contentstore/tests/test_libraries.py
+++ b/cms/djangoapps/contentstore/tests/test_libraries.py
@@ -386,6 +386,7 @@ def test_refreshes_children_if_libraries_change(self):
html_block = modulestore().get_item(lc_block.children[0])
self.assertEqual(html_block.data, data2)
+ @patch("xmodule.library_tools.SearchEngine.get_search_engine", Mock(return_value=None))
def test_refreshes_children_if_capa_type_change(self):
""" Tests that children are automatically refreshed if capa type field changes """
name1, name2 = "Option Problem", "Multiple Choice Problem"
@@ -459,6 +460,7 @@ def test_refresh_fails_for_unknown_library(self):
@ddt.ddt
+@patch('django.conf.settings.SEARCH_ENGINE', None)
class TestLibraryAccess(SignalDisconnectTestMixin, LibraryTestCase):
"""
Test Roles and Permissions related to Content Libraries
diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py
index 350ef47f175c..710351ae4c1d 100644
--- a/cms/djangoapps/contentstore/tests/tests.py
+++ b/cms/djangoapps/contentstore/tests/tests.py
@@ -6,12 +6,14 @@
import unittest
from ddt import ddt, data, unpack
+from django.test import TestCase
from django.test.utils import override_settings
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
+from contentstore.models import PushNotificationConfig
from contentstore.tests.utils import parse_json, user, registration, AjaxEnabledTestClient
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from contentstore.tests.test_course_settings import CourseTestCase
@@ -349,3 +351,15 @@ def test_course_key_decorator(self, course_key, status_code):
)
resp = self.client.get_html(url)
self.assertEqual(resp.status_code, status_code)
+
+
+class PushNotificationConfigTestCase(TestCase):
+ """
+ Tests PushNotificationConfig.
+ """
+ def test_notifications_defaults(self):
+ self.assertFalse(PushNotificationConfig.is_enabled())
+
+ def test_notifications_enabled(self):
+ PushNotificationConfig(enabled=True).save()
+ self.assertTrue(PushNotificationConfig.is_enabled())
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 7db80dc44f6d..916435fa0589 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -22,7 +22,6 @@
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from xmodule.contentstore.content import StaticContent
from xmodule.tabs import PDFTextbookTabs
from xmodule.partitions.partitions import UserPartition
@@ -35,6 +34,7 @@
from django_future.csrf import ensure_csrf_cookie
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
+from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from contentstore.utils import (
add_instructor,
initialize_permissions,
@@ -69,6 +69,7 @@
from .library import LIBRARIES_ENABLED
from .item import create_xblock_info
+from contentstore.push_notification import push_notification_enabled
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils
from student.roles import (
@@ -778,7 +779,8 @@ def course_info_handler(request, course_key_string):
'context_course': course_module,
'updates_url': reverse_course_url('course_info_update_handler', course_key),
'handouts_locator': course_key.make_usage_key('course_info', 'handouts'),
- 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.id)
+ 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(course_module.id),
+ 'push_notification_enabled': push_notification_enabled()
}
)
else:
diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py
index f163607047a8..a6b1f85fc49c 100644
--- a/cms/djangoapps/contentstore/views/entrance_exam.py
+++ b/cms/djangoapps/contentstore/views/entrance_exam.py
@@ -10,7 +10,7 @@
from django_future.csrf import ensure_csrf_cookie
from django.http import HttpResponse, HttpResponseBadRequest
-from contentstore.views.helpers import create_xblock
+from contentstore.views.helpers import create_xblock, remove_entrance_exam_graders
from contentstore.views.item import delete_item
from models.settings.course_metadata import CourseMetadata
from opaque_keys.edx.keys import CourseKey, UsageKey
@@ -133,7 +133,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
# Create the entrance exam item (currently it's just a chapter)
payload = {
'category': "chapter",
- 'display_name': "Entrance Exam",
+ 'display_name': _("Entrance Exam"),
'parent_locator': unicode(course.location),
'is_entrance_exam': True,
'in_entrance_exam': True,
@@ -143,7 +143,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
parent_locator=parent_locator,
user=request.user,
category='chapter',
- display_name='Entrance Exam',
+ display_name=_('Entrance Exam'),
is_entrance_exam=True
)
@@ -177,7 +177,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
else:
description = 'Autogenerated during {} entrance exam creation.'.format(unicode(course.id))
milestone = milestones_helpers.add_milestone({
- 'name': 'Completed Course Entrance Exam',
+ 'name': _('Completed Course Entrance Exam'),
'namespace': milestone_namespace,
'description': description
})
@@ -270,6 +270,9 @@ def _delete_entrance_exam(request, course_key):
}
CourseMetadata.update_from_dict(metadata, course, request.user)
+ # Clean up any pre-existing entrance exam graders
+ remove_entrance_exam_graders(course_key, request.user)
+
return HttpResponse(status=204)
diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py
index ce65e281ca7b..72df1bc44d39 100644
--- a/cms/djangoapps/contentstore/views/helpers.py
+++ b/cms/djangoapps/contentstore/views/helpers.py
@@ -25,6 +25,17 @@
__all__ = ['edge', 'event', 'landing']
+# Note: Grader types are used throughout the platform but most usages are simply in-line
+# strings. In addition, new grader types can be defined on the fly anytime one is needed
+# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio.
+GRADER_TYPES = {
+ "HOMEWORK": "Homework",
+ "LAB": "Lab",
+ "ENTRANCE_EXAM": "Entrance Exam",
+ "MIDTERM_EXAM": "Midterm Exam",
+ "FINAL_EXAM": "Final Exam"
+}
+
# points to the temporary course landing page with log in and sign up
def landing(request, org, course, coursename):
@@ -145,7 +156,7 @@ def xblock_type_display_name(xblock, default_display_name=None):
return _('Unit')
component_class = XBlock.load_class(category, select=settings.XBLOCK_SELECT_FUNCTION)
if hasattr(component_class, 'display_name') and component_class.display_name.default:
- return _(component_class.display_name.default)
+ return _(component_class.display_name.default) # pylint: disable=translation-of-non-string
else:
return default_display_name
@@ -173,6 +184,18 @@ def usage_key_with_run(usage_key_string):
return usage_key
+def remove_entrance_exam_graders(course_key, user):
+ """
+ Removes existing entrance exam graders attached to the specified course
+ Typically used when adding/removing an entrance exam.
+ """
+ grading_model = CourseGradingModel.fetch(course_key)
+ graders = grading_model.graders
+ for i, grader in enumerate(graders):
+ if grader['type'] == GRADER_TYPES['ENTRANCE_EXAM']:
+ CourseGradingModel.delete_grader(course_key, i, user)
+
+
def create_xblock(parent_locator, user, category, display_name, boilerplate=None, is_entrance_exam=False):
"""
Performs the actual grunt work of creating items/xblocks -- knows nothing about requests, views, etc.
@@ -228,11 +251,14 @@ def create_xblock(parent_locator, user, category, display_name, boilerplate=None
# Entrance Exams: Grader assignment
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
- course = store.get_course(usage_key.course_key)
+ course_key = usage_key.course_key
+ course = store.get_course(course_key)
if hasattr(course, 'entrance_exam_enabled') and course.entrance_exam_enabled:
if category == 'sequential' and parent_locator == course.entrance_exam_id:
+ # Clean up any pre-existing entrance exam graders
+ remove_entrance_exam_graders(course_key, user)
grader = {
- "type": "Entrance Exam",
+ "type": GRADER_TYPES['ENTRANCE_EXAM'],
"min_count": 0,
"drop_count": 0,
"short_label": "Entrance",
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 10f2bfed56dc..a50cbc2e1654 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -813,8 +813,14 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
if parent_xblock is None:
parent_xblock = get_parent_xblock(xblock)
- explanatory_message = _('Students must score {score}% or higher to access course materials.').format(
- score=int(parent_xblock.entrance_exam_minimum_score_pct * 100))
+ # Translators: The {pct_sign} here represents the percent sign, i.e., '%'
+ # in many languages. This is used to avoid Transifex's misinterpreting of
+ # '% o'. The percent sign is also translatable as a standalone string.
+ explanatory_message = _('Students must score {score}{pct_sign} or higher to access course materials.').format(
+ score=int(parent_xblock.entrance_exam_minimum_score_pct * 100),
+ # Translators: This is the percent sign. It will be used to represent
+ # a percent value out of 100, e.g. "58%" means "58/100".
+ pct_sign=_('%'))
xblock_info = {
"id": unicode(xblock.location),
diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py
index 8a454d9d063a..daa7450f2b77 100644
--- a/cms/djangoapps/contentstore/views/tests/test_assets.py
+++ b/cms/djangoapps/contentstore/views/tests/test_assets.py
@@ -14,9 +14,11 @@
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
+from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.xml_importer import import_course_from_xml
from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey, AssetLocation
+from static_replace import replace_static_urls
import mock
from ddt import ddt
from ddt import data
@@ -86,6 +88,44 @@ def test_pdf_asset(self):
# Note: Actual contentType for textbook.pdf in asset.json is 'text/pdf'
self.assertEqual(content.content_type, 'application/pdf')
+ def test_relative_url_for_split_course(self):
+ """
+ Test relative path for split courses assets
+ """
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ module_store = modulestore()
+ course_id = module_store.make_course_key('edX', 'toy', '2012_Fall')
+ import_course_from_xml(
+ module_store,
+ self.user.id,
+ TEST_DATA_DIR,
+ ['toy'],
+ static_content_store=contentstore(),
+ target_id=course_id,
+ create_if_not_present=True
+ )
+ course = module_store.get_course(course_id)
+
+ filename = 'sample_static.txt'
+ html_src_attribute = '"/static/{}"'.format(filename)
+ asset_url = replace_static_urls(html_src_attribute, course_id=course.id)
+ url = asset_url.replace('"', '')
+ base_url = url.replace(filename, '')
+
+ self.assertTrue("/{}".format(filename) in url)
+ resp = self.client.get(url)
+ self.assertEquals(resp.status_code, 200)
+
+ # simulation of html page where base_url is up-to asset's main directory
+ # and relative_path is dom element with its src
+ relative_path = 'just_a_test.jpg'
+ # browser append relative_path with base_url
+ absolute_path = base_url + relative_path
+
+ self.assertTrue("/{}".format(relative_path) in absolute_path)
+ resp = self.client.get(absolute_path)
+ self.assertEquals(resp.status_code, 200)
+
class PaginationTestCase(AssetsTestCase):
"""
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index 6c110839663a..2c7ee1474835 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -6,28 +6,28 @@
import datetime
import os
import mock
+import pytz
+
+from django.conf import settings
+from django.core.exceptions import PermissionDenied
+from django.utils.translation import ugettext as _
+from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor
-from student.auth import has_course_author_access
from contentstore.views.course import course_outline_initial_state, reindex_course_and_check_access
from contentstore.views.item import create_xblock_info, VisibilityState
+from course_action_state.managers import CourseRerunUIStateManager
from course_action_state.models import CourseRerunState
+from opaque_keys.edx.locator import CourseLocator
+from search.api import perform_search
+from student.auth import has_course_author_access
+from student.tests.factories import UserFactory
from util.date_utils import get_default_time_display
from xmodule.modulestore import ModuleStoreEnum
-from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory
-from xmodule.modulestore.courseware_index import SearchIndexingError
-from opaque_keys.edx.locator import CourseLocator
-from student.tests.factories import UserFactory
-from course_action_state.managers import CourseRerunUIStateManager
-from django.conf import settings
-from django.core.exceptions import PermissionDenied
-from django.utils.translation import ugettext as _
-from search.api import perform_search
-import pytz
class TestCourseIndex(CourseTestCase):
@@ -346,8 +346,6 @@ class TestCourseReIndex(CourseTestCase):
Unit tests for the course outline.
"""
- TEST_INDEX_FILENAME = "test_root/index_file.dat"
-
SUCCESSFUL_RESPONSE = _("Course has been successfully reindexed.")
def setUp(self):
@@ -379,12 +377,6 @@ def setUp(self):
)
- # create test file in which index for this test will live
- with open(self.TEST_INDEX_FILENAME, "w+") as index_file:
- json.dump({}, index_file)
-
- self.addCleanup(os.remove, self.TEST_INDEX_FILENAME)
-
def test_reindex_course(self):
"""
Verify that course gets reindexed.
@@ -445,25 +437,19 @@ def test_reindex_json_responses(self):
"""
Test json response with real data
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
-
- # Start manual reindex
- reindex_course_and_check_access(self.course.id, self.user)
-
- self.html.display_name = "My expanded HTML"
- modulestore().update_item(self.html, ModuleStoreEnum.UserID.test)
+ self.assertEqual(response['total'], 1)
# Start manual reindex
reindex_course_and_check_access(self.course.id, self.user)
- # Check results indexed now
+ # Check results remain the same
response = perform_search(
"unique",
user=self.user,
@@ -477,14 +463,14 @@ def test_reindex_video_error_json_responses(self, mock_index_dictionary):
"""
Test json response with mocked error data for video
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
+ self.assertEqual(response['total'], 1)
# set mocked exception response
err = SearchIndexingError
@@ -499,14 +485,14 @@ def test_reindex_html_error_json_responses(self, mock_index_dictionary):
"""
Test json response with mocked error data for html
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
+ self.assertEqual(response['total'], 1)
# set mocked exception response
err = SearchIndexingError
@@ -521,14 +507,14 @@ def test_reindex_seq_error_json_responses(self, mock_index_dictionary):
"""
Test json response with mocked error data for sequence
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
+ self.assertEqual(response['total'], 1)
# set mocked exception response
err = Exception
@@ -559,27 +545,21 @@ def test_reindex_no_permissions(self):
def test_indexing_responses(self):
"""
- Test add_to_search_index response with real data
+ Test do_course_reindex response with real data
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
-
- # Start manual reindex
- CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
-
- self.html.display_name = "My expanded HTML"
- modulestore().update_item(self.html, ModuleStoreEnum.UserID.test)
+ self.assertEqual(response['total'], 1)
# Start manual reindex
CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id)
- # Check results indexed now
+ # Check results are the same following reindex
response = perform_search(
"unique",
user=self.user,
@@ -591,16 +571,16 @@ def test_indexing_responses(self):
@mock.patch('xmodule.video_module.VideoDescriptor.index_dictionary')
def test_indexing_video_error_responses(self, mock_index_dictionary):
"""
- Test add_to_search_index response with mocked error data for video
+ Test do_course_reindex response with mocked error data for video
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
+ self.assertEqual(response['total'], 1)
# set mocked exception response
err = Exception
@@ -613,16 +593,16 @@ def test_indexing_video_error_responses(self, mock_index_dictionary):
@mock.patch('xmodule.html_module.HtmlDescriptor.index_dictionary')
def test_indexing_html_error_responses(self, mock_index_dictionary):
"""
- Test add_to_search_index response with mocked error data for html
+ Test do_course_reindex response with mocked error data for html
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
+ self.assertEqual(response['total'], 1)
# set mocked exception response
err = Exception
@@ -635,16 +615,16 @@ def test_indexing_html_error_responses(self, mock_index_dictionary):
@mock.patch('xmodule.seq_module.SequenceDescriptor.index_dictionary')
def test_indexing_seq_error_responses(self, mock_index_dictionary):
"""
- Test add_to_search_index response with mocked error data for sequence
+ Test do_course_reindex response with mocked error data for sequence
"""
- # Check results not indexed
+ # results are indexed because they are published from ItemFactory
response = perform_search(
"unique",
user=self.user,
size=10,
from_=0,
course_id=unicode(self.course.id))
- self.assertEqual(response['results'], [])
+ self.assertEqual(response['total'], 1)
# set mocked exception response
err = Exception
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_updates.py b/cms/djangoapps/contentstore/views/tests/test_course_updates.py
index a45c7b1db582..94f92fe6377c 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_updates.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_updates.py
@@ -2,7 +2,10 @@
unit tests for course_info views and models.
"""
import json
+from mock import patch
+from django.test.utils import override_settings
+from contentstore.models import PushNotificationConfig
from contentstore.tests.test_course_settings import CourseTestCase
from contentstore.utils import reverse_course_url, reverse_usage_url
from opaque_keys.edx.keys import UsageKey
@@ -234,18 +237,19 @@ def test_no_ol_course_update(self):
payload = json.loads(resp.content)
self.assertTrue(len(payload) == 1)
- def test_post_course_update(self):
+ def post_course_update(self, send_push_notification=False):
"""
- Test that a user can successfully post on course updates and handouts of a course
+ Posts an update to the course
"""
course_update_url = self.create_update_url(course_key=self.course.id)
# create a course via the view handler
self.client.ajax_post(course_update_url)
- block = u'updates'
content = u"Sample update"
payload = {'content': content, 'date': 'January 8, 2013'}
+ if send_push_notification:
+ payload['push_notification_selected'] = True
resp = self.client.ajax_post(course_update_url, payload)
# check that response status is 200 not 400
@@ -254,9 +258,19 @@ def test_post_course_update(self):
payload = json.loads(resp.content)
self.assertHTMLEqual(payload['content'], content)
+ @patch("contentstore.push_notification.send_push_course_update")
+ def test_post_course_update(self, mock_push_update):
+ """
+ Test that a user can successfully post on course updates and handouts of a course
+ """
+ self.post_course_update()
+
+ # check that push notifications are not sent
+ self.assertFalse(mock_push_update.called)
+
updates_location = self.course.id.make_usage_key('course_info', 'updates')
self.assertTrue(isinstance(updates_location, UsageKey))
- self.assertEqual(updates_location.name, block)
+ self.assertEqual(updates_location.name, u'updates')
# check posting on handouts
handouts_location = self.course.id.make_usage_key('course_info', 'handouts')
@@ -265,8 +279,38 @@ def test_post_course_update(self):
content = u"Sample handout"
payload = {'data': content}
resp = self.client.ajax_post(course_handouts_url, payload)
+
# check that response status is 200 not 500
self.assertEqual(resp.status_code, 200)
payload = json.loads(resp.content)
self.assertHTMLEqual(payload['data'], content)
+
+ @patch("contentstore.push_notification.send_push_course_update")
+ def test_notifications_enabled_but_not_requested(self, mock_push_update):
+ PushNotificationConfig(enabled=True).save()
+ self.post_course_update()
+ self.assertFalse(mock_push_update.called)
+
+ @patch("contentstore.push_notification.send_push_course_update")
+ def test_notifications_enabled_and_sent(self, mock_push_update):
+ PushNotificationConfig(enabled=True).save()
+ self.post_course_update(send_push_notification=True)
+ self.assertTrue(mock_push_update.called)
+
+ @override_settings(PARSE_KEYS={"APPLICATION_ID": "TEST_APPLICATION_ID", "REST_API_KEY": "TEST_REST_API_KEY"})
+ @patch("contentstore.push_notification.Push")
+ def test_notifications_sent_to_parse(self, mock_parse_push):
+ PushNotificationConfig(enabled=True).save()
+ self.post_course_update(send_push_notification=True)
+ self.assertEquals(mock_parse_push.alert.call_count, 2)
+
+ @override_settings(PARSE_KEYS={"APPLICATION_ID": "TEST_APPLICATION_ID", "REST_API_KEY": "TEST_REST_API_KEY"})
+ @patch("contentstore.push_notification.log_exception")
+ @patch("contentstore.push_notification.Push")
+ def test_notifications_error_from_parse(self, mock_parse_push, mock_log_exception):
+ PushNotificationConfig(enabled=True).save()
+ from parse_rest.core import ParseError
+ mock_parse_push.alert.side_effect = ParseError
+ self.post_course_update(send_push_notification=True)
+ self.assertTrue(mock_log_exception.called)
diff --git a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py
index 0a720f98e8bf..3f07a1bcba78 100644
--- a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py
+++ b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py
@@ -11,6 +11,7 @@
from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase
from contentstore.utils import reverse_url
from contentstore.views.entrance_exam import create_entrance_exam, update_entrance_exam, delete_entrance_exam
+from contentstore.views.helpers import GRADER_TYPES
from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata
from opaque_keys.edx.keys import UsageKey
@@ -84,7 +85,7 @@ def test_contentstore_views_entrance_exam_post_new_sequential_confirm_grader(sel
seq_locator_string = json.loads(resp.content).get('locator')
seq_locator = UsageKey.from_string(seq_locator_string)
section_grader_type = CourseGradingModel.get_section_grader_type(seq_locator)
- self.assertEqual('Entrance Exam', section_grader_type['graderType'])
+ self.assertEqual(GRADER_TYPES['ENTRANCE_EXAM'], section_grader_type['graderType'])
def test_contentstore_views_entrance_exam_get(self):
"""
@@ -140,6 +141,14 @@ def test_contentstore_views_entrance_exam_delete(self):
resp = self.client.get(self.exam_url)
self.assertEqual(resp.status_code, 200)
+ # Confirm that we have only one Entrance Exam grader after re-adding the exam (validates SOL-475)
+ graders = CourseGradingModel.fetch(self.course_key).graders
+ count = 0
+ for grader in graders:
+ if grader['type'] == GRADER_TYPES['ENTRANCE_EXAM']:
+ count += 1
+ self.assertEqual(count, 1)
+
def test_contentstore_views_entrance_exam_delete_bogus_course(self):
"""
Unit Test: test_contentstore_views_entrance_exam_delete_bogus_course
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index afb215e0186a..ab96448dad95 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -215,7 +215,7 @@ def recompose_video_tag(video_key):
# the right thing
result = None
if video_key:
- result = ''
return result
diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py
index 3356531b84b1..fadc3d5a878e 100644
--- a/cms/djangoapps/models/settings/course_metadata.py
+++ b/cms/djangoapps/models/settings/course_metadata.py
@@ -69,6 +69,11 @@ def filtered_list(cls):
if not settings.FEATURES.get('ENABLE_MOBILE_SOCIAL_FACEBOOK_FEATURES'):
filtered_list.append('facebook_url')
+ # Do not show social sharing url field if the feature is disabled.
+ if (not settings.FEATURES.get('DASHBOARD_SHARE_SETTINGS') or
+ not settings.FEATURES.get("DASHBOARD_SHARE_SETTINGS").get("CUSTOM_COURSE_URLS")):
+ filtered_list.append('social_sharing_url')
+
return filtered_list
@classmethod
diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index e78bae1c4110..319ed99e46ed 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -320,6 +320,11 @@
VIDEO_UPLOAD_PIPELINE = ENV_TOKENS.get('VIDEO_UPLOAD_PIPELINE', VIDEO_UPLOAD_PIPELINE)
+################ PUSH NOTIFICATIONS ###############
+
+PARSE_KEYS = AUTH_TOKENS.get("PARSE_KEYS", {})
+
+
#date format the api will be formatting the datetime values
API_DATE_FORMAT = '%Y-%m-%d'
API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
@@ -328,7 +333,7 @@
# Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='}
VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {})
-if FEATURES['ENABLE_COURSEWARE_INDEX']:
+if FEATURES['ENABLE_COURSEWARE_INDEX'] or FEATURES['ENABLE_LIBRARY_INDEX']:
# Use ElasticSearch for the search engine
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json
index 2be0a45e6676..af6f6002d85a 100644
--- a/cms/envs/bok_choy.env.json
+++ b/cms/envs/bok_choy.env.json
@@ -65,6 +65,9 @@
"FEATURES": {
"AUTH_USE_OPENID_PROVIDER": true,
"CERTIFICATES_ENABLED": true,
+ "DASHBOARD_SHARE_SETTINGS": {
+ "CUSTOM_COURSE_URLS": true
+ },
"ENABLE_DISCUSSION_SERVICE": true,
"ENABLE_INSTRUCTOR_ANALYTICS": true,
"ENABLE_S3_GRADE_DOWNLOADS": true,
diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py
index eb5e32193b9a..0f0cdb85cb89 100644
--- a/cms/envs/bok_choy.py
+++ b/cms/envs/bok_choy.py
@@ -78,6 +78,7 @@
YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT)
FEATURES['ENABLE_COURSEWARE_INDEX'] = True
+FEATURES['ENABLE_LIBRARY_INDEX'] = True
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
# Path at which to store the mock index
MOCK_SEARCH_BACKING_FILE = (
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 999413ea2ab1..445df80d30bf 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -36,7 +36,12 @@
# Although this module itself may not use these imported variables, other dependent modules may.
from lms.envs.common import (
USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL, DOC_STORE_CONFIG, DATA_DIR, ALL_LANGUAGES, WIKI_ENABLED,
- update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR
+ update_module_store_settings, ASSET_IGNORE_REGEX, COPYRIGHT_YEAR, PARENTAL_CONSENT_AGE_LIMIT,
+ # The following PROFILE_IMAGE_* settings are included as they are
+ # indirectly accessed through the email opt-in API, which is
+ # technically accessible through the CMS via legacy URLs.
+ PROFILE_IMAGE_BACKEND, PROFILE_IMAGE_DEFAULT_FILENAME, PROFILE_IMAGE_DEFAULT_FILE_EXTENSION,
+ PROFILE_IMAGE_SECRET_KEY, PROFILE_IMAGE_MIN_BYTES, PROFILE_IMAGE_MAX_BYTES,
)
from path import path
from warnings import simplefilter
@@ -140,8 +145,17 @@
# Enable the courseware search functionality
'ENABLE_COURSEWARE_INDEX': False,
+ # Enable content libraries search functionality
+ 'ENABLE_LIBRARY_INDEX': False,
+
# Enable course reruns, which will always use the split modulestore
'ALLOW_COURSE_RERUNS': True,
+
+ # Social Media Sharing on Student Dashboard
+ 'DASHBOARD_SHARE_SETTINGS': {
+ # Note: Ensure 'CUSTOM_COURSE_URLS' has a matching value in lms/envs/common.py
+ 'CUSTOM_COURSE_URLS': False
+ }
}
ENABLE_JASMINE = False
@@ -793,6 +807,7 @@
OPTIONAL_APPS = (
'mentoring',
+ 'problem_builder',
'edx_sga',
# edx-ora2
@@ -857,6 +872,8 @@
'lti',
'library_content',
'edx_sga',
+ 'problem-builder',
+ 'pb-dashboard',
# XBlocks from pmitros repos are prototypes. They should not be used
# except for edX Learning Sciences experiments on edge.edx.org without
# further work to make them robust, maintainable, finalize data formats,
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index 533b88bcc799..3aa7f47b0008 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -80,6 +80,7 @@ def should_show_debug_toolbar(_):
################################ SEARCH INDEX ################################
FEATURES['ENABLE_COURSEWARE_INDEX'] = True
+FEATURES['ENABLE_LIBRARY_INDEX'] = True
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
###############################################################################
diff --git a/cms/envs/test.py b/cms/envs/test.py
index a5e330953da5..2d10aaad4947 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -249,6 +249,9 @@
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
+# Enable a parental consent age limit for testing
+PARENTAL_CONSENT_AGE_LIMIT = 13
+
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
@@ -267,11 +270,8 @@
# Courseware Search Index
FEATURES['ENABLE_COURSEWARE_INDEX'] = True
+FEATURES['ENABLE_LIBRARY_INDEX'] = True
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
-# Path at which to store the mock index
-MOCK_SEARCH_BACKING_FILE = (
- TEST_ROOT / "index_file.dat" # pylint: disable=no-value-for-parameter
-).abspath()
# Dummy secret key for dev/test
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee
index e9846b80d790..8bf4e41f36ad 100644
--- a/cms/static/coffee/spec/views/course_info_spec.coffee
+++ b/cms/static/coffee/spec/views/course_info_spec.coffee
@@ -22,7 +22,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
delete window.analytics
delete window.course_location_analytics
- describe "Course Updates", ->
+ describe "Course Updates without Push notification", ->
courseInfoTemplate = readFixtures('course_info_update.underscore')
beforeEach ->
@@ -100,7 +100,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
else
modalCover.click()
- it "does not rewrite links on save", ->
+ it "does send expected data on save", ->
requests = AjaxHelpers["requests"](this)
# Create a new update, verifying that the model is created
@@ -116,9 +116,12 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
@courseInfoEdit.$el.find('.save-button').click()
expect(model.save).toHaveBeenCalled()
- # Verify content sent to server does not have rewritten links.
- contentSaved = JSON.parse(requests[requests.length - 1].requestBody).content
- expect(contentSaved).toEqual('/static/image.jpg')
+ # Verify push_notification_selected is set to false.
+ requestSent = JSON.parse(requests[requests.length - 1].requestBody)
+ expect(requestSent.push_notification_selected).toEqual(false)
+
+ # Verify the link is not rewritten when saved.
+ expect(requestSent.content).toEqual('/static/image.jpg')
it "does rewrite links for preview", ->
# Create a new update.
@@ -147,6 +150,41 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model
it "does not remove existing course info on click outside modal", ->
@cancelExistingCourseInfo(false)
+ describe "Course Updates WITH Push notification", ->
+ courseInfoTemplate = readFixtures('course_info_update.underscore')
+
+ beforeEach ->
+ setFixtures($("
-
-
Enter two integers which sum to 10:
-
-
-
-
-
-
Enter two integers which sum to 20:
-
-
-
-
-
-
-
-
Explanation
-
Any set of integers on the line \(y = 10 - x\) and \(y = 20 - x\) satisfy these constraints.
-
You can also add images within the solution clause like so:
-
-
-
+
+
+
+
+
Explanation
+
Any set of integers on the line \(y = 10 - x\) and \(y = 20 - x\) satisfy these constraints.
+
To add an image to the solution, use an HTML "img" tag. Make sure to include alt text.
+
+
+
diff --git a/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml b/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml
index 6f087763df25..bfdf72a1cc7e 100644
--- a/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/drag_and_drop.yaml
@@ -4,22 +4,36 @@ metadata:
markdown: !!null
data: |
- Here's an example of a "Drag and Drop" question set. Click and drag each word in the scrollbar below, up to the numbered bucket which matches the number of letters in the word.
+
+ In drag and drop problems, students respond to a question by dragging text or objects to a specific location on an image.
+
+ In math expression input problems, learners enter text that represents a
+ mathematical expression into a field, and text is converted to a symbolic
+ expression that appears below that field. You can refer learners to
+
+ Entering Mathematical and Scientific Expressions in the edX Guide for
+ Students for information about how to enter text into the field.
+
+
+ Math expression problems can include unknown variables and relatively
+ complicated symbolic expressions. The grader uses a numerical sampling to
+ determine whether the student’s response matches your math expression, to a
+ specified numerical tolerance. You must specify the allowed variables in the
+ expression as well as the range of values for each variable.
+
+
+ To create these problems, you use MathJax to change your plain text into
+ "beautiful math." For more information about how to use MathJax in Studio,
+ see
+ A Brief Introduction to MathJax in Studio in Building and Running an edx
+ Course.
+
+
When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
You can use the following example problems as models.
- A math expression input problem accepts a line of text representing a mathematical expression from the
- student, and evaluates the input for equivalence to a mathematical expression provided by the
- grader. Correctness is based on numerical sampling of the symbolic expressions.
-
-
- The answer is correct if both the student provided response and the grader's mathematical
- expression are equivalent to specified numerical tolerance, over a specified range of values for each
- variable.
-
-
-
This kind of response checking can handle symbolic expressions, but places an extra burden
- on the problem author to specify the allowed variables in the expression, and the
- numerical ranges over which the variables must be sampled in order to test for correctness.
-
-
-
Give an equation for the relativistic energy of an object with mass m. Explicitly indicate multiplication with a * symbol.
-
-
- E =
-
-
-
The answer to this question is (R_1*R_2)/R_3.
-
-
-
-
-
-
-
-
Explanation
-
The mathematical summary of many of the theory of relativity developed by Einstein is that the amount of energy contained in a mass m is the mass time the speed of light squared.
-
As you can see with the formula entry, the answer is \(\frac{R_1*R_2}{R_3}\)
- An image mapped input problem presents an image for the student.
- Input is given by the location of mouse clicks on the image.
- Correctness of input can be evaluated based on expected dimensions of a rectangle.
-
-
Which animal shown below is a kitten?
+
+ In an image mapped input problem, also known as a "pointing on a picture"
+ problem, students click inside a defined region in an image. You define this
+ region by including coordinates in the body of the problem. You can define
+ one rectangular region, multiple rectangular regions, or one non-rectangular
+ region. For more information, see
+ Image Mapped Input
+ Problem in Building and Running an edx Course.
+
+
When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
You can use the following example problem as a model.
+ In these problems (also called custom JavaScript problems or JS Input
+ problems), you add a problem or tool that uses JavaScript in Studio.
+ Studio embeds the problem in an IFrame so that your students can
+ interact with it in the LMS. You can grade your students' work using
+ JavaScript and some basic Python, and the grading is integrated into the
+ edX grading system.
+
+
+ The JS Input problem that you create must use HTML, JavaScript, and cascading
+ style sheets (CSS). You can use any application creation tool, such as the
+ Google Web Toolkit (GWT), to create your JS Input problem.
+
- The shapes below can be selected (yellow) or unselected (cyan).
- Clicking on them repeatedly will cycle through these two states.
-
-
- If the cone is selected (and not the cube), a correct answer will be
- generated after pressing "Check". Clicking on either "Check" or "Save"
- will register the current state.
-
+
In the following image, click the objects until the cone is yellow
+ and the cube is blue.
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\subsection{Example show/hide explanation}
- Extra explanations can be tucked away behind a "showhide" toggle flag:
+ You can provide additional information that only appears at certain times by including a "showhide" flag.
- \edXshowhide{sh1}{More explanation}{This is a hidden explanation. It
- can contain equations: $\alpha = \frac{2}{\sqrt{1+\gamma}}$ }
+ \edXshowhide{sh1}{More explanation}{This is a hidden explanation. It
+ can contain equations, such as $\alpha = \frac{2}{\sqrt{1+\gamma}}$ }.
- This is some text after the showhide example.
+ This is additional text after the hidden explanation.
markdown: !!null
data: |
-
-
-
Example "option" problem
-
-
- Where is the earth?
-
+
If you have a problem that is already written in LaTeX, you can use this problem type to
+ easily convert your code into XML. After you paste your code into the LaTeX editor,
+ you only need to make a few minor adjustments.
- This is a hidden explanation. It can contain equations: [mathjaxinline]\alpha = \frac{2}{\sqrt {1+\gamma }}[/mathjaxinline]
-
- This is some text after the showhide example.
+
This is a hidden explanation. It can contain equations, such as [mathjaxinline]\alpha = \frac{2}{\sqrt {1+\gamma }}[/mathjaxinline].
+
This is additional text after the hidden explanation.
-
-
+
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
index a222d1993993..2aab5d17f9b1 100644
--- a/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/multiplechoice.yaml
@@ -2,40 +2,53 @@
metadata:
display_name: Multiple Choice
markdown: |
- A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
-
- One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
-
- >>What Apple device competed with the portable CD player?<<
- ( ) The iPad
- ( ) Napster
- (x) The iPod
- ( ) The vegetable peeler
-
+ Multiple choice problems allow learners to select only one option. Learners can see all the options along with the problem text.
+
+ When you add the problem, be sure to select Settings to specify a Display Name and other values that apply.
+
+ You can use the following example problem as a model.
+ _____________________________________________________________________________
+
+ >>Which of the following countries has the largest population?<<
+ ( ) Brazil
+ ( ) Germany
+ (x) Indonesia
+ ( ) Russia
+
[explanation]
- The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
+ According to September 2014 estimates:
+ The population of Indonesia is approximately 250 million.
+ The population of Brazil is approximately 200 million.
+ The population of Russia is approximately 146 million.
+ The population of Germany is approximately 81 million.
[explanation]
+
data: |
-
- A multiple choice problem presents radio buttons for student
- input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
-
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
-
-
-
What Apple device competed with the portable CD player?
-
-
- The iPad
- Napster
- The iPod
- The vegetable peeler
-
-
-
-
-
Explanation
-
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
-
-
-
+
Multiple choice problems allow learners to select only one option.
+ Learners can see all the options along with the problem text.
+
When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
You can use the following example problem as a model.
+
+
+
+
Explanation
+
According to September 2014 estimates:
+
The population of Indonesia is approximately 250 million.
+
The population of Brazil is approximately 200 million.
+
The population of Russia is approximately 146 million.
+
The population of Germany is approximately 81 million.
+
+
+
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
index 2cf092223e7a..8d886d3b3034 100644
--- a/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/numericalresponse.yaml
@@ -2,66 +2,66 @@
metadata:
display_name: Numerical Input
markdown: |
- A numerical input problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.
+ In a numerical input problem, learners enter numbers or a specific and relatively simple mathematical expression. Learners enter the response in plain text, and the system then converts the text to a symbolic expression that learners can see below the response field.
- The answer is correct if it is within a specified numerical tolerance of the expected answer.
+ The system can handle several types of characters, including basic operators, fractions, exponents, and common constants such as "i". You can refer learners to "Entering Mathematical and Scientific Expressions" in the edX Guide for Students for more information.
- >>Enter the numerical value of Pi:<<
- = 3.14159 +- .02
-
- >>Enter the approximate value of 502*9:<<
- = 4518 +- 15%
+ When you add the problem, be sure to select Settings to specify a Display Name and other values that apply.
- >>Enter the number of fingers on a human hand<<
- = 5
+ You can use the following example problems as models.
+ _____________________________________________________________________________
- [explanation]
- Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
+ >>How many miles away from Earth is the sun? Use scientific notation to answer.<<
- Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.
+ = 9.3*10^6
+ or= 9.296*10^6
- If you look at your hand, you can count that you have five fingers.
- [explanation]
+ >>The square of what number is -100?<<
+
+ = 10*i
+
+ [explanation]
+ The sun is 93,000,000, or 9.3*10^6, miles away from Earth.
+ -100 is the square of 10 times the imaginary number, i.
+ [explanation]
data: |
-
-
- A numerical input problem accepts a line of text input from the
- student, and evaluates the input for correctness based on its
- numerical value.
-
+
+
+
In a numerical input problem, learners enter numbers or a specific and
+ relatively simple mathematical expression. Learners enter the response in
+ plain text, and the system then converts the text to a symbolic expression
+ that learners can see below the response field.
+
+
The system can handle several types of characters, including basic
+ operators, fractions, exponents, and common constants such as i. You can
+ refer learners to
+
+ Entering Mathematical and Scientific Expressions in the edX Guide for
+ Students for information about how to enter text into the field.
+
When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
-
- The answer is correct if it is within a specified numerical tolerance
- of the expected answer.
-
-
+
You can use the following example problems as models.
Enter the number of fingers on a human hand:
-
-
-
-
-
-
-
Explanation
-
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
-
Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.
-
If you look at your hand, you can count that you have five fingers.
-
-
-
+
+
+
+
Explanation
+
The sun is 93,000,000, or 9.3*10^6, miles away from Earth.
+
-100 is the square of 10 times the imaginary number, i.
+
+
+
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
index 1e661fd0af49..25de5fa3fc23 100644
--- a/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/optionresponse.yaml
@@ -2,35 +2,39 @@
metadata:
display_name: Dropdown
markdown: |
- Dropdown problems give a limited set of options for students to respond with, and present those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
+ Dropdown problems allow learners to select only one option from a list of options.
- The answer options and the identification of the correct answer is defined in the optioninput tag.
+ When you add the problem, be sure to select Settings to specify a Display Name and other values that apply.
- >>Translation between Dropdown and __________ is extremely straightforward:<<
+ You can use the following example problem as a model.
- [[(Multiple Choice), Text Input, Numerical Input, External Response, Image Response]]
+ _____________________________________________________________________________
- [explanation]
- Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Dropdowns also differ slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.
- [explanation]
+ >>Which of the following countries celebrates its independence on August 15?<<
+
+ [[(India), Spain, China, Bermuda]]
+
+ [explanation]
+ India became an independent nation on August 15, 1947.
+ [explanation]
data: |
-
Dropdown problems give a limited set of options for students to respond with, and present those options
- in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
-
-
- The answer options and the identification of the correct answer is defined in the optioninput tag.
-
-
Translation between Dropdown and __________ is extremely straightforward:
-
-
-
-
-
-
-
-
Explanation
-
Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.
-
-
+
Dropdown problems allow learners to select only one option from a list of options.
+
When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
You can use the following example problem as a model.
+
+
+
+
Explanation
+
India became an independent nation on August 15, 1947.
+
+
+
diff --git a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
index 008a6bf6472c..701934f40ed2 100644
--- a/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
+++ b/common/lib/xmodule/xmodule/templates/problem/string_response.yaml
@@ -2,38 +2,45 @@
metadata:
display_name: Text Input
markdown: |
- A text input problem accepts a line of text from the student, and evaluates the input for correctness based on an expected answer.
-
- The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.
-
- >>Which US state has Lansing as its capital?<<
-
- = Michigan
+ In text input problems, also known as "fill-in-the-blank" problems, learners enter text into a response field. The text can include letters and characters such as punctuation marks. The text that the learner enters must match your specified answer text exactly. You can specify more than one correct answer. Learners must enter a response that matches one of the correct answers exactly.
+ When you add the problem, be sure to select Settings to specify a Display Name and other values that apply.
+
+ You can use the following example problem as a model.
+ _____________________________________________________________________________
+
+ >>What was the first post-secondary school in China to allow both male and female students?<<
+
+ = Nanjing Higher Normal Institute
+ or= National Central University
+ or= Nanjing University
[explanation]
- Lansing is the capital of Michigan, although it is not Michigan's largest city, or even the seat of the county in which it resides.
+ Nanjing Higher Normal Institute first admitted female students in 1920.
[explanation]
+
data: |
-
-
- A text input problem accepts a line of text from the
- student, and evaluates the input for correctness based on an expected
- answer.
-
-
- The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.
-
-
-
Which US state has Lansing as its capital?
-
-
-
-
-
-
Explanation
-
Lansing is the capital of Michigan, although it is not Michigan's largest city, or even the seat of the county in which it resides.
-
-
+
In text input problems, also known as "fill-in-the-blank" problems,
+ learners enter text into a response field. The text that the learner enters
+ must match your specified answer text exactly. You can specify more than
+ one correct answer. Learners must enter a response that matches one of the
+ correct answers exactly.
+
When you add the problem, be sure to select Settings
+ to specify a Display Name and other values that apply.
+
You can use the following example problem as a model.
+
+
+
+
Explanation
+
Nanjing Higher Normal Institute first admitted female students in 1920.
+
+
diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py
index 65003ccd4962..b8b339ff4ac7 100644
--- a/common/lib/xmodule/xmodule/tests/__init__.py
+++ b/common/lib/xmodule/xmodule/tests/__init__.py
@@ -16,8 +16,6 @@
import unittest
from contextlib import contextmanager, nested
-from eventtracking import tracker
-from eventtracking.django import DjangoTracker
from functools import wraps
from lazy import lazy
from mock import Mock, patch
@@ -37,7 +35,6 @@
from xmodule.modulestore.xml import CourseLocationManager
from xmodule.x_module import ModuleSystem, XModuleDescriptor, XModuleMixin
-
MODULE_DIR = path(__file__).dirname()
# Location of common test DATA directory
# '../../../../edx-platform/common/test/data/'
@@ -58,14 +55,11 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method
"""
ModuleSystem for testing
"""
- @patch('eventtracking.tracker.emit')
- def __init__(self, mock_emit, **kwargs): # pylint: disable=unused-argument
+ def __init__(self, **kwargs): # pylint: disable=unused-argument
id_manager = CourseLocationManager(kwargs['course_id'])
kwargs.setdefault('id_reader', id_manager)
kwargs.setdefault('id_generator', id_manager)
kwargs.setdefault('services', {}).setdefault('field-data', DictFieldData({}))
- self.tracker = DjangoTracker()
- tracker.register_tracker(self.tracker)
super(TestModuleSystem, self).__init__(**kwargs)
def handler_url(self, block, handler, suffix='', query='', thirdparty=False):
diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
index 2c666743aeb7..e88e59782da7 100644
--- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
@@ -1659,18 +1659,26 @@ def test_check_unmask_answerpool(self):
@ddt.ddt
class CapaDescriptorTest(unittest.TestCase):
- def _create_descriptor(self, xml):
+ def _create_descriptor(self, xml, name=None):
""" Creates a CapaDescriptor to run test against """
descriptor = CapaDescriptor(get_test_system(), scope_ids=1)
descriptor.data = xml
+ if name:
+ descriptor.display_name = name
return descriptor
@ddt.data(*responsetypes.registry.registered_tags())
def test_all_response_types(self, response_tag):
""" Tests that every registered response tag is correctly returned """
xml = "<{response_tag}>{response_tag}>".format(response_tag=response_tag)
- descriptor = self._create_descriptor(xml)
+ name = "Some Capa Problem"
+ descriptor = self._create_descriptor(xml, name=name)
self.assertEquals(descriptor.problem_types, {response_tag})
+ self.assertEquals(descriptor.index_dictionary(), {
+ 'content_type': CapaDescriptor.INDEX_CONTENT_TYPE,
+ 'display_name': name,
+ 'problem_types': [response_tag]
+ })
def test_response_types_ignores_non_response_tags(self):
xml = textwrap.dedent("""
@@ -1687,8 +1695,14 @@ def test_response_types_ignores_non_response_tags(self):
""")
- descriptor = self._create_descriptor(xml)
+ name = "Test Capa Problem"
+ descriptor = self._create_descriptor(xml, name=name)
self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse"})
+ self.assertEquals(descriptor.index_dictionary(), {
+ 'content_type': CapaDescriptor.INDEX_CONTENT_TYPE,
+ 'display_name': name,
+ 'problem_types': ["multiplechoiceresponse"]
+ })
def test_response_types_multiple_tags(self):
xml = textwrap.dedent("""
@@ -1710,8 +1724,16 @@ def test_response_types_multiple_tags(self):
""")
- descriptor = self._create_descriptor(xml)
+ name = "Other Test Capa Problem"
+ descriptor = self._create_descriptor(xml, name=name)
self.assertEquals(descriptor.problem_types, {"multiplechoiceresponse", "optionresponse"})
+ self.assertEquals(
+ descriptor.index_dictionary(), {
+ 'content_type': CapaDescriptor.INDEX_CONTENT_TYPE,
+ 'display_name': name,
+ 'problem_types': ["optionresponse", "multiplechoiceresponse"]
+ }
+ )
class ComplexEncoderTest(unittest.TestCase):
diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py
index fd9313f8463c..cbe9ad301867 100644
--- a/common/lib/xmodule/xmodule/tests/test_library_content.py
+++ b/common/lib/xmodule/xmodule/tests/test_library_content.py
@@ -18,6 +18,7 @@
from xmodule.tests import get_test_system
from xmodule.validation import StudioValidationMessage
from xmodule.x_module import AUTHOR_VIEW
+from search.search_engine_base import SearchEngine
dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-name
@@ -66,10 +67,17 @@ def get_module(descriptor):
module.xmodule_runtime = module_system
-class TestLibraryContentModule(LibraryContentTest):
+class LibraryContentModuleTestMixin(object):
"""
Basic unit tests for LibraryContentModule
"""
+ problem_types = [
+ ["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
+ ["coderesponse", "optionresponse"]
+ ]
+
+ problem_type_lookup = {}
+
def _get_capa_problem_type_xml(self, *args):
""" Helper function to create empty CAPA problem definition """
problem = ""
@@ -84,12 +92,10 @@ def _create_capa_problems(self):
Creates four blocks total.
"""
- problem_types = [
- ["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
- ["coderesponse", "optionresponse"]
- ]
- for problem_type in problem_types:
- self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type))
+ self.problem_type_lookup = {}
+ for problem_type in self.problem_types:
+ block = self.make_block("problem", self.library, data=self._get_capa_problem_type_xml(*problem_type))
+ self.problem_type_lookup[block.location] = problem_type
def test_lib_content_block(self):
"""
@@ -236,6 +242,42 @@ def test_non_editable_settings(self):
self.assertNotIn(LibraryContentDescriptor.display_name, non_editable_metadata_fields)
+@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=None))
+class TestLibraryContentModuleNoSearchIndex(LibraryContentModuleTestMixin, LibraryContentTest):
+ """
+ Tests for library container when no search index is available.
+ Tests fallback low-level CAPA problem introspection
+ """
+ pass
+
+
+search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name
+
+
+@patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=search_index_mock))
+class TestLibraryContentModuleWithSearchIndex(LibraryContentModuleTestMixin, LibraryContentTest):
+ """
+ Tests for library container with mocked search engine response.
+ """
+ def _get_search_response(self, field_dictionary=None):
+ """ Mocks search response as returned by search engine """
+ target_type = field_dictionary.get('problem_types')
+ matched_block_locations = [
+ key for key, problem_types in
+ self.problem_type_lookup.items() if target_type in problem_types
+ ]
+ return {
+ 'results': [
+ {'data': {'id': str(location)}} for location in matched_block_locations
+ ]
+ }
+
+ def setUp(self):
+ """ Sets up search engine mock """
+ super(TestLibraryContentModuleWithSearchIndex, self).setUp()
+ search_index_mock.search = Mock(side_effect=self._get_search_response)
+
+
@patch(
'xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.render', VanillaRuntime.render
)
diff --git a/common/lib/xmodule/xmodule/vertical_block.py b/common/lib/xmodule/xmodule/vertical_block.py
index 293c8657905b..1f2b4b874f96 100644
--- a/common/lib/xmodule/xmodule/vertical_block.py
+++ b/common/lib/xmodule/xmodule/vertical_block.py
@@ -128,3 +128,23 @@ def studio_view(self, context):
# TODO: Remove this when studio better supports editing of pure XBlocks.
fragment.add_javascript('VerticalBlock = XModule.Descriptor;')
return fragment
+
+ def index_dictionary(self):
+ """
+ Return dictionary prepared with module content and type for indexing.
+ """
+ # return key/value fields in a Python dict object
+ # values may be numeric / string or dict
+ # default implementation is an empty dict
+ xblock_body = super(VerticalBlock, self).index_dictionary()
+ index_body = {
+ "display_name": self.display_name,
+ }
+ if "content" in xblock_body:
+ xblock_body["content"].update(index_body)
+ else:
+ xblock_body["content"] = index_body
+ # We use "Sequence" for sequentials and verticals
+ xblock_body["content_type"] = "Sequence"
+
+ return xblock_body
diff --git a/common/lib/xmodule/xmodule/video_module/video_xfields.py b/common/lib/xmodule/xmodule/video_module/video_xfields.py
index c943e32a2e4d..fa551a63f27a 100644
--- a/common/lib/xmodule/xmodule/video_module/video_xfields.py
+++ b/common/lib/xmodule/xmodule/video_module/video_xfields.py
@@ -52,13 +52,21 @@ class VideoFields(object):
default=""
)
start_time = RelativeTime( # datetime.timedelta object
- help=_("Time you want the video to start if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59."),
+ help=_(
+ "Time you want the video to start if you don't want the entire video to play. "
+ "Not supported in the native mobile app: the full video file will play. "
+ "Formatted as HH:MM:SS. The maximum value is 23:59:59."
+ ),
display_name=_("Video Start Time"),
scope=Scope.settings,
default=datetime.timedelta(seconds=0)
)
end_time = RelativeTime( # datetime.timedelta object
- help=_("Time you want the video to stop if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59."),
+ help=_(
+ "Time you want the video to stop if you don't want the entire video to play. "
+ "Not supported in the native mobile app: the full video file will play. "
+ "Formatted as HH:MM:SS. The maximum value is 23:59:59."
+ ),
display_name=_("Video Stop Time"),
scope=Scope.settings,
default=datetime.timedelta(seconds=0)
@@ -160,8 +168,8 @@ class VideoFields(object):
default=False
)
edx_video_id = String(
- help=_("If you were assigned a Video ID by edX for the video to play in this component, enter the ID here. In this case, do not enter values in the Default Video URL, the Video File URLs, and the YouTube ID fields. If you were not assigned an edX Video ID, enter values in those other fields and ignore this field."),
- display_name=_("EdX Video ID"),
+ help=_("If you were assigned a Video ID by edX for the video to play in this component, enter the ID here. In this case, do not enter values in the Default Video URL, the Video File URLs, and the YouTube ID fields. If you were not assigned a Video ID, enter values in those other fields and ignore this field."), # pylint: disable=line-too-long
+ display_name=_("Video ID"),
scope=Scope.settings,
default="",
)
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 08bbc08e53a4..437000d8ad1e 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -39,6 +39,7 @@
XMODULE_METRIC_NAME = 'edxapp.xmodule'
XMODULE_DURATION_METRIC_NAME = XMODULE_METRIC_NAME + '.duration'
+XMODULE_METRIC_SAMPLE_RATE = 0.1
# Stats event sent to DataDog in order to determine if old XML parsing can be deprecated.
DEPRECATION_VSCOMPAT_EVENT = 'deprecation.vscompat'
@@ -554,6 +555,8 @@ def bind_for_student(self, xmodule_runtime, field_data, user_id):
# Skip rebinding if we're already bound a user, and it's this user.
if self.scope_ids.user_id is not None and user_id == self.scope_ids.user_id:
+ if getattr(xmodule_runtime, 'position', None):
+ self.position = xmodule_runtime.position # update the position of the tab
return
# If we are switching users mid-request, save the data from the old user.
@@ -1193,13 +1196,15 @@ def render(self, block, view_name, context=None):
u'action:render',
u'action_status:{}'.format(status),
u'course_id:{}'.format(course_id),
- u'block_type:{}'.format(block.scope_ids.block_type)
+ u'block_type:{}'.format(block.scope_ids.block_type),
+ u'block_family:{}'.format(block.entry_point),
]
- dog_stats_api.increment(XMODULE_METRIC_NAME, tags=tags)
+ dog_stats_api.increment(XMODULE_METRIC_NAME, tags=tags, sample_rate=XMODULE_METRIC_SAMPLE_RATE)
dog_stats_api.histogram(
XMODULE_DURATION_METRIC_NAME,
end_time - start_time,
- tags=tags
+ tags=tags,
+ sample_rate=XMODULE_METRIC_SAMPLE_RATE,
)
def handle(self, block, handler_name, request, suffix=''):
@@ -1220,13 +1225,15 @@ def handle(self, block, handler_name, request, suffix=''):
u'action:handle',
u'action_status:{}'.format(status),
u'course_id:{}'.format(course_id),
- u'block_type:{}'.format(block.scope_ids.block_type)
+ u'block_type:{}'.format(block.scope_ids.block_type),
+ u'block_family:{}'.format(block.entry_point),
]
- dog_stats_api.increment(XMODULE_METRIC_NAME, tags=tags)
+ dog_stats_api.increment(XMODULE_METRIC_NAME, tags=tags, sample_rate=XMODULE_METRIC_SAMPLE_RATE)
dog_stats_api.histogram(
XMODULE_DURATION_METRIC_NAME,
end_time - start_time,
- tags=tags
+ tags=tags,
+ sample_rate=XMODULE_METRIC_SAMPLE_RATE
)
diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js
index f2be1a44fb8d..e9b97696e4bf 100644
--- a/common/static/js/spec_helpers/ajax_helpers.js
+++ b/common/static/js/spec_helpers/ajax_helpers.js
@@ -1,6 +1,6 @@
define(['sinon', 'underscore'], function(sinon, _) {
- var fakeServer, fakeRequests, expectRequest, expectJsonRequest,
- respondWithJson, respondWithError, respondWithTextError, respondToDelete;
+ var fakeServer, fakeRequests, expectRequest, expectJsonRequest, expectPostRequest,
+ respondWithJson, respondWithError, respondWithTextError, respondWithNoContent;
/* These utility methods are used by Jasmine tests to create a mock server or
* get reference to mock requests. In either case, the cleanup (restore) is done with
@@ -68,6 +68,20 @@ define(['sinon', 'underscore'], function(sinon, _) {
expect(JSON.parse(request.requestBody)).toEqual(jsonRequest);
};
+ /**
+ * Intended for use with POST requests using application/x-www-form-urlencoded.
+ */
+ expectPostRequest = function(requests, url, body, requestIndex) {
+ var request;
+ if (_.isUndefined(requestIndex)) {
+ requestIndex = requests.length - 1;
+ }
+ request = requests[requestIndex];
+ expect(request.url).toEqual(url);
+ expect(request.method).toEqual("POST");
+ expect(_.difference(request.requestBody.split('&'), body.split('&'))).toEqual([]);
+ };
+
respondWithJson = function(requests, jsonResponse, requestIndex) {
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
@@ -109,7 +123,7 @@ define(['sinon', 'underscore'], function(sinon, _) {
);
};
- respondToDelete = function(requests, requestIndex) {
+ respondWithNoContent = function(requests, requestIndex) {
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
@@ -122,9 +136,10 @@ define(['sinon', 'underscore'], function(sinon, _) {
'requests': fakeRequests,
'expectRequest': expectRequest,
'expectJsonRequest': expectJsonRequest,
+ 'expectPostRequest': expectPostRequest,
'respondWithJson': respondWithJson,
'respondWithError': respondWithError,
'respondWithTextError': respondWithTextError,
- 'respondToDelete': respondToDelete
+ 'respondWithNoContent': respondWithNoContent,
};
});
diff --git a/common/templates/course_modes/_contribution.html b/common/templates/course_modes/_contribution.html
deleted file mode 100644
index ecab7280c5c5..000000000000
--- a/common/templates/course_modes/_contribution.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
${_("Sorry, there was an error when trying to enroll you")}
- <%include file="/verify_student/_verification_header.html" args="course_name=course_name" />
-