diff --git a/cms/urls.py b/cms/urls.py
index 4a216e1883..4ca692f109 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -80,6 +80,11 @@ urlpatterns += patterns(
'course_info_update_handler'
),
url(r'^home/$', 'course_listing', name='home'),
+ url(
+ r'^course_search_index/{}?$'.format(settings.COURSE_KEY_PATTERN),
+ 'course_search_index_handler',
+ name='course_search_index_handler'
+ ),
url(r'^course/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'),
url(r'^course_notifications/{}/(?P\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_notifications_handler'),
url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index acea5a3e55..13ca86894b 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -17,7 +17,8 @@ import textwrap
from xmodule.contentstore.content import StaticContent
from xblock.core import XBlock
from xmodule.edxnotes_utils import edxnotes
-
+from xmodule.annotator_mixin import html_to_text
+import re
log = logging.getLogger("edx.courseware")
@@ -253,6 +254,25 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
non_editable_fields.append(HtmlDescriptor.use_latex_compiler)
return non_editable_fields
+ def index_dictionary(self):
+ xblock_body = super(HtmlDescriptor, self).index_dictionary()
+ # Removing HTML-encoded non-breaking space characters
+ html_content = re.sub(r"(\s| |//)+", " ", html_to_text(self.data))
+ # Removing HTML CDATA
+ html_content = re.sub(r"", "", html_content)
+ # Removing HTML comments
+ html_content = re.sub(r"", "", html_content)
+ html_body = {
+ "html_content": html_content,
+ "display_name": self.display_name,
+ }
+ if "content" in xblock_body:
+ xblock_body["content"].update(html_body)
+ else:
+ xblock_body["content"] = html_body
+ xblock_body["content_type"] = "HTML Content"
+ return xblock_body
+
class AboutFields(object):
display_name = String(
diff --git a/common/lib/xmodule/xmodule/modulestore/courseware_index.py b/common/lib/xmodule/xmodule/modulestore/courseware_index.py
new file mode 100644
index 0000000000..1aebdd3bab
--- /dev/null
+++ b/common/lib/xmodule/xmodule/modulestore/courseware_index.py
@@ -0,0 +1,135 @@
+""" Code to allow module store to interface with courseware index """
+from __future__ import absolute_import
+
+import logging
+
+from django.utils.translation import ugettext as _
+from opaque_keys.edx.locator import CourseLocator
+from search.search_engine_base import SearchEngine
+
+from . import ModuleStoreEnum
+from .exceptions import ItemNotFoundError
+
+# Use default index and document names for now
+INDEX_NAME = "courseware_index"
+DOCUMENT_TYPE = "courseware_content"
+
+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
+
+
+class CoursewareSearchIndexer(object):
+ """
+ Class to perform indexing for courseware search from different modulestores
+ """
+
+ @staticmethod
+ def add_to_search_index(modulestore, location, delete=False, raise_on_error=False):
+ """
+ Add to courseware search index from given location and its children
+ """
+ error_list = []
+ # TODO - inline for now, need to move this out to a celery task
+ searcher = SearchEngine.get_search_engine(INDEX_NAME)
+ if not searcher:
+ return
+
+ if isinstance(location, CourseLocator):
+ course_key = location
+ else:
+ course_key = location.course_key
+
+ location_info = {
+ "course": unicode(course_key),
+ }
+
+ def _fetch_item(item_location):
+ """ Fetch the item from the modulestore location, log if not found, but continue """
+ try:
+ if isinstance(item_location, CourseLocator):
+ item = modulestore.get_course(item_location)
+ else:
+ item = modulestore.get_item(item_location, revision=ModuleStoreEnum.RevisionOption.published_only)
+ except ItemNotFoundError:
+ log.warning('Cannot find: %s', item_location)
+ return None
+
+ return item
+
+ def index_item_location(item_location, current_start_date):
+ """ add this item to the search index """
+ item = _fetch_item(item_location)
+ if not item:
+ return
+
+ is_indexable = hasattr(item, "index_dictionary")
+ # if it's not indexable and it does not have children, then ignore
+ if not is_indexable and not item.has_children:
+ return
+
+ # if it has a defined start, then apply it and to it's children
+ if item.start and (not current_start_date or item.start > current_start_date):
+ current_start_date = item.start
+
+ if item.has_children:
+ for child_loc in item.children:
+ index_item_location(child_loc, current_start_date)
+
+ item_index = {}
+ item_index_dictionary = item.index_dictionary() if is_indexable else None
+
+ # if it has something to add to the index, then add it
+ if item_index_dictionary:
+ try:
+ item_index.update(location_info)
+ item_index.update(item_index_dictionary)
+ item_index['id'] = unicode(item.scope_ids.usage_id)
+ if current_start_date:
+ item_index['start_date'] = current_start_date
+
+ searcher.index(DOCUMENT_TYPE, item_index)
+ 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 - %s', item_location, unicode(err))
+ error_list.append(_('Could not index item: {}').format(item_location))
+
+ def remove_index_item_location(item_location):
+ """ remove this item from the search index """
+ item = _fetch_item(item_location)
+ if item:
+ if item.has_children:
+ for child_loc in item.children:
+ remove_index_item_location(child_loc)
+
+ searcher.remove(DOCUMENT_TYPE, unicode(item.scope_ids.usage_id))
+
+ try:
+ if delete:
+ remove_index_item_location(location)
+ else:
+ index_item_location(location, None)
+ 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 - %s",
+ course_key,
+ unicode(err)
+ )
+ error_list.append(_('General indexing error occurred'))
+
+ if raise_on_error and error_list:
+ raise SearchIndexingError(_('Error(s) present during indexing'), error_list)
+
+ @classmethod
+ def do_course_reindex(cls, modulestore, course_key):
+ """
+ (Re)index all content within the given course
+ """
+ return cls.add_to_search_index(modulestore, course_key, delete=False, raise_on_error=True)
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
index eaf808d18d..a0d8001359 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py
@@ -278,7 +278,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
raw_metadata = json_data.get('metadata', {})
# published_on was previously stored as a list of time components instead of a datetime
if raw_metadata.get('published_date'):
- module._edit_info['published_date'] = datetime(*raw_metadata.get('published_date')[0:6]).replace(tzinfo=UTC)
+ module._edit_info['published_date'] = datetime(
+ *raw_metadata.get('published_date')[0:6]
+ ).replace(tzinfo=UTC)
module._edit_info['published_by'] = raw_metadata.get('published_by')
# decache any computed pending field settings
@@ -1804,3 +1806,25 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
# To allow prioritizing draft vs published material
self.collection.create_index('_id.revision')
+
+ # Some overrides that still need to be implemented by subclasses
+ def convert_to_draft(self, location, user_id):
+ raise NotImplementedError()
+
+ def delete_item(self, location, user_id, **kwargs):
+ raise NotImplementedError()
+
+ def has_changes(self, xblock):
+ raise NotImplementedError()
+
+ def has_published_version(self, xblock):
+ raise NotImplementedError()
+
+ def publish(self, location, user_id):
+ raise NotImplementedError()
+
+ def revert_to_published(self, location, user_id):
+ raise NotImplementedError()
+
+ def unpublish(self, location, user_id):
+ raise NotImplementedError()
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
index d603287b08..fc1c174b8f 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo/draft.py
@@ -12,6 +12,7 @@ import logging
from opaque_keys.edx.locations import Location
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
from xmodule.modulestore.exceptions import (
ItemNotFoundError, DuplicateItemError, DuplicateCourseError, InvalidBranchSetting
)
@@ -509,7 +510,8 @@ class DraftModuleStore(MongoModuleStore):
parent_locations = [draft_parent.location]
# there could be 2 parents if
# Case 1: the draft item moved from one parent to another
- # Case 2: revision==ModuleStoreEnum.RevisionOption.all and the single parent has 2 versions: draft and published
+ # Case 2: revision==ModuleStoreEnum.RevisionOption.all and the single
+ # parent has 2 versions: draft and published
for parent_location in parent_locations:
# don't remove from direct_only parent if other versions of this still exists (this code
# assumes that there's only one parent_location in this case)
@@ -541,6 +543,10 @@ class DraftModuleStore(MongoModuleStore):
)
self._delete_subtree(location, as_functions)
+ # Remove this location from the courseware search index so that searches
+ # will refrain from showing it as a result
+ CoursewareSearchIndexer.add_to_search_index(self, location, delete=True)
+
def _delete_subtree(self, location, as_functions, draft_only=False):
"""
Internal method for deleting all of the subtree whose revisions match the as_functions
@@ -713,6 +719,10 @@ class DraftModuleStore(MongoModuleStore):
bulk_record = self._get_bulk_ops_record(location.course_key)
bulk_record.dirty = True
self.collection.remove({'_id': {'$in': to_be_deleted}})
+
+ # Now it's been published, add the object to the courseware search index so that it appears in search results
+ CoursewareSearchIndexer.add_to_search_index(self, location)
+
return self.get_item(as_published(location))
def unpublish(self, location, user_id, **kwargs):
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
index 7ebb9d94e5..1340f06c2b 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
@@ -5,6 +5,7 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
from xmodule.modulestore.draft_and_published import (
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
@@ -203,6 +204,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
if branch == ModuleStoreEnum.BranchName.draft and branched_location.block_type in DIRECT_ONLY_CATEGORIES:
self.publish(parent_loc.version_agnostic(), user_id, blacklist=EXCLUDE_ALL, **kwargs)
+ # Remove this location from the courseware search index so that searches
+ # will refrain from showing it as a result
+ CoursewareSearchIndexer.add_to_search_index(self, location, delete=True)
+
def _map_revision_to_branch(self, key, revision=None):
"""
Maps RevisionOptions to BranchNames, inserting them into the key
@@ -345,6 +350,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
[location],
blacklist=blacklist
)
+
+ # Now it's been published, add the object to the courseware search index so that it appears in search results
+ CoursewareSearchIndexer.add_to_search_index(self, location)
+
return self.get_item(location.for_branch(ModuleStoreEnum.BranchName.published), **kwargs)
def unpublish(self, location, user_id, **kwargs):
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py
index 2156f0d18f..eab9168c1a 100644
--- a/common/lib/xmodule/xmodule/seq_module.py
+++ b/common/lib/xmodule/xmodule/seq_module.py
@@ -188,3 +188,22 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
for child in self.get_children():
self.runtime.add_block_as_child_node(child, xml_object)
return xml_object
+
+ 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(SequenceDescriptor, self).index_dictionary()
+ html_body = {
+ "display_name": self.display_name,
+ }
+ if "content" in xblock_body:
+ xblock_body["content"].update(html_body)
+ else:
+ xblock_body["content"] = html_body
+ xblock_body["content_type"] = self.category.title()
+
+ return xblock_body
diff --git a/common/lib/xmodule/xmodule/tests/test_html_module.py b/common/lib/xmodule/xmodule/tests/test_html_module.py
index da1e0d49a6..e55882ebe5 100644
--- a/common/lib/xmodule/xmodule/tests/test_html_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_html_module.py
@@ -3,9 +3,25 @@ import unittest
from mock import Mock
from xblock.field_data import DictFieldData
-from xmodule.html_module import HtmlModule
+from xmodule.html_module import HtmlModule, HtmlDescriptor
-from . import get_test_system
+from . import get_test_system, get_test_descriptor_system
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from xblock.fields import ScopeIds
+
+
+def instantiate_descriptor(**field_data):
+ """
+ Instantiate descriptor with most properties.
+ """
+ system = get_test_descriptor_system()
+ course_key = SlashSeparatedCourseKey('org', 'course', 'run')
+ usage_key = course_key.make_usage_key('html', 'SampleHtml')
+ return system.construct_xblock_from_class(
+ HtmlDescriptor,
+ scope_ids=ScopeIds(None, None, usage_key, usage_key),
+ field_data=DictFieldData(field_data),
+ )
class HtmlModuleSubstitutionTestCase(unittest.TestCase):
@@ -36,3 +52,71 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase):
module_system.anonymous_student_id = None
module = HtmlModule(self.descriptor, module_system, field_data, Mock())
self.assertEqual(module.get_html(), sample_xml)
+
+
+class HtmlDescriptorIndexingTestCase(unittest.TestCase):
+ """
+ Make sure that HtmlDescriptor can format data for indexing as expected.
+ """
+
+ def test_index_dictionary(self):
+ sample_xml = '''
+
+
+
+
+ '''
+ descriptor = instantiate_descriptor(data=sample_xml_comment)
+ self.assertEqual(descriptor.index_dictionary(), {
+ "content": {"html_content": " This has HTML comment in it. ", "display_name": "Text"},
+ "content_type": "HTML Content"
+ })
+
+ sample_xml_mix_comment_cdata = '''
+
+
+
This has HTML comment in it.
+
+
+
HTML end.
+
+ '''
+ descriptor = instantiate_descriptor(data=sample_xml_mix_comment_cdata)
+ self.assertEqual(descriptor.index_dictionary(), {
+ "content": {"html_content": " This has HTML comment in it. HTML end. ", "display_name": "Text"},
+ "content_type": "HTML Content"
+ })
diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py
index f92c3f9cb1..de9de5dea6 100644
--- a/common/lib/xmodule/xmodule/tests/test_video.py
+++ b/common/lib/xmodule/xmodule/tests/test_video.py
@@ -14,6 +14,7 @@ the course, section, subsection, unit, etc.
"""
import unittest
import datetime
+from uuid import uuid4
from mock import Mock, patch
from . import LogicTest
@@ -25,6 +26,63 @@ from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from xmodule.tests import get_test_descriptor_system
+from xmodule.video_module.transcripts_utils import download_youtube_subs, save_to_store
+
+from django.conf import settings
+from django.test.utils import override_settings
+
+SRT_FILEDATA = '''
+0
+00:00:00,270 --> 00:00:02,720
+sprechen sie deutsch?
+
+1
+00:00:02,720 --> 00:00:05,430
+Ja, ich spreche Deutsch
+'''
+
+CRO_SRT_FILEDATA = '''
+0
+00:00:00,270 --> 00:00:02,720
+Dobar dan!
+
+1
+00:00:02,720 --> 00:00:05,430
+Kako ste danas?
+'''
+
+
+TEST_YOU_TUBE_SETTINGS = {
+ # YouTube JavaScript API
+ 'API': 'www.youtube.com/iframe_api',
+
+ # URL to test YouTube availability
+ 'TEST_URL': 'gdata.youtube.com/feeds/api/videos/',
+
+ # Current youtube api for requesting transcripts.
+ # For example: http://video.google.com/timedtext?lang=en&v=j_jEn79vS3g.
+ 'TEXT_API': {
+ 'url': 'video.google.com/timedtext',
+ 'params': {
+ 'lang': 'en',
+ 'v': 'set_youtube_id_of_11_symbols_here',
+ },
+ },
+}
+
+TEST_DATA_CONTENTSTORE = {
+ 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
+ 'DOC_STORE_CONFIG': {
+ 'host': 'localhost',
+ 'db': 'test_xcontent_%s' % uuid4().hex,
+ },
+ # allow for additional options that can be keyed on a name, e.g. 'trashcan'
+ 'ADDITIONAL_OPTIONS': {
+ 'trashcan': {
+ 'bucket': 'trash_fs'
+ }
+ }
+}
def instantiate_descriptor(**field_data):
@@ -505,7 +563,8 @@ class VideoExportTestCase(VideoDescriptorTestBase):
self.descriptor.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'}
xml = self.descriptor.definition_to_xml(None) # We don't use the `resource_fs` parameter
- expected = etree.fromstring('''\
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
- ''')
+ '''
+ expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml)
def test_export_to_xml_empty_end_time(self):
@@ -534,14 +594,15 @@ class VideoExportTestCase(VideoDescriptorTestBase):
self.descriptor.download_video = True
xml = self.descriptor.definition_to_xml(None) # We don't use the `resource_fs` parameter
- expected = etree.fromstring('''\
+ parser = etree.XMLParser(remove_blank_text=True)
+ xml_string = '''\
- ''')
-
+ '''
+ expected = etree.XML(xml_string, parser=parser)
self.assertXmlEqual(expected, xml)
def test_export_to_xml_empty_parameters(self):
@@ -582,3 +643,155 @@ class VideoCdnTest(unittest.TestCase):
cdn_response.return_value = Mock(status_code=404)
fake_cdn_url = 'http://fake_cdn.com/'
self.assertIsNone(get_video_from_cdn(fake_cdn_url, original_video_url))
+
+
+@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
+@override_settings(YOUTUBE=TEST_YOU_TUBE_SETTINGS)
+class VideoDescriptorIndexingTestCase(unittest.TestCase):
+ """
+ Make sure that VideoDescriptor can format data for indexing as expected.
+ """
+
+ def test_index_dictionary(self):
+ xml_data = '''
+
+ '''
+ descriptor = instantiate_descriptor(data=xml_data)
+ self.assertEqual(descriptor.index_dictionary(), {
+ "content": {"display_name": "Test Video"},
+ "content_type": "Video"
+ })
+
+ xml_data_sub = '''
+
+ '''
+
+ descriptor = instantiate_descriptor(data=xml_data_sub)
+ download_youtube_subs('OEoXaMPEzfM', descriptor, settings)
+ self.assertEqual(descriptor.index_dictionary(), {
+ "content": {
+ "display_name": "Test Video",
+ "transcript_en": (
+ "LILA FISHER: Hi, welcome to Edx. I'm Lila Fisher, an Edx fellow helping to put together these"
+ "courses. As you know, our courses are entirely online. So before we start learning about the"
+ "subjects that brought you here, let's learn about the tools that you will use to navigate through"
+ "the course material. Let's start with what is on your screen right now. You are watching a video"
+ "of me talking. You have several tools associated with these videos. Some of them are standard"
+ "video buttons, like the play Pause Button on the bottom left. Like most video players, you can see"
+ "how far you are into this particular video segment and how long the entire video segment is."
+ "Something that you might not be used to is the speed option. While you are going through the"
+ "videos, you can speed up or slow down the video player with these buttons. Go ahead and try that"
+ "now. Make me talk faster and slower. If you ever get frustrated by the pace of speech, you can"
+ "adjust it this way. Another great feature is the transcript on the side. This will follow along"
+ "with everything that I am saying as I am saying it, so you can read along if you like. You can"
+ "also click on any of the words, and you will notice that the video jumps to that word. The video"
+ "slider at the bottom of the video will let you navigate through the video quickly. If you ever"
+ "find the transcript distracting, you can toggle the captioning button in order to make it go away"
+ "or reappear. Now that you know about the video player, I want to point out the sequence navigator."
+ "Right now you're in a lecture sequence, which interweaves many videos and practice exercises. You"
+ "can see how far you are in a particular sequence by observing which tab you're on. You can"
+ "navigate directly to any video or exercise by clicking on the appropriate tab. You can also"
+ "progress to the next element by pressing the Arrow button, or by clicking on the next tab. Try"
+ "that now. The tutorial will continue in the next video."
+ )
+ },
+ "content_type": "Video"
+ })
+
+ xml_data_sub_transcript = '''
+
+ '''
+
+ descriptor = instantiate_descriptor(data=xml_data_sub_transcript)
+ save_to_store(SRT_FILEDATA, "subs_grmtran1.srt", 'text/srt', descriptor.location)
+ self.assertEqual(descriptor.index_dictionary(), {
+ "content": {
+ "display_name": "Test Video",
+ "transcript_en": (
+ "LILA FISHER: Hi, welcome to Edx. I'm Lila Fisher, an Edx fellow helping to put together these"
+ "courses. As you know, our courses are entirely online. So before we start learning about the"
+ "subjects that brought you here, let's learn about the tools that you will use to navigate through"
+ "the course material. Let's start with what is on your screen right now. You are watching a video"
+ "of me talking. You have several tools associated with these videos. Some of them are standard"
+ "video buttons, like the play Pause Button on the bottom left. Like most video players, you can see"
+ "how far you are into this particular video segment and how long the entire video segment is."
+ "Something that you might not be used to is the speed option. While you are going through the"
+ "videos, you can speed up or slow down the video player with these buttons. Go ahead and try that"
+ "now. Make me talk faster and slower. If you ever get frustrated by the pace of speech, you can"
+ "adjust it this way. Another great feature is the transcript on the side. This will follow along"
+ "with everything that I am saying as I am saying it, so you can read along if you like. You can"
+ "also click on any of the words, and you will notice that the video jumps to that word. The video"
+ "slider at the bottom of the video will let you navigate through the video quickly. If you ever"
+ "find the transcript distracting, you can toggle the captioning button in order to make it go away"
+ "or reappear. Now that you know about the video player, I want to point out the sequence navigator."
+ "Right now you're in a lecture sequence, which interweaves many videos and practice exercises. You"
+ "can see how far you are in a particular sequence by observing which tab you're on. You can"
+ "navigate directly to any video or exercise by clicking on the appropriate tab. You can also"
+ "progress to the next element by pressing the Arrow button, or by clicking on the next tab. Try"
+ "that now. The tutorial will continue in the next video."
+ ),
+ "transcript_ge": "sprechen sie deutsch? Ja, ich spreche Deutsch"
+ },
+ "content_type": "Video"
+ })
+
+ xml_data_transcripts = '''
+
+ '''
+
+ descriptor = instantiate_descriptor(data=xml_data_transcripts)
+ save_to_store(SRT_FILEDATA, "subs_grmtran1.srt", 'text/srt', descriptor.location)
+ save_to_store(CRO_SRT_FILEDATA, "subs_croatian1.srt", 'text/srt', descriptor.location)
+ self.assertEqual(descriptor.index_dictionary(), {
+ "content": {
+ "display_name": "Test Video",
+ "transcript_ge": "sprechen sie deutsch? Ja, ich spreche Deutsch",
+ "transcript_hr": "Dobar dan! Kako ste danas?"
+ },
+ "content_type": "Video"
+ })
diff --git a/common/lib/xmodule/xmodule/video_module/video_module.py b/common/lib/xmodule/xmodule/video_module/video_module.py
index 2a374ab2f9..56467e91a4 100644
--- a/common/lib/xmodule/xmodule/video_module/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module/video_module.py
@@ -32,6 +32,7 @@ from xmodule.x_module import XModule, module_attr
from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field
+from xmodule.exceptions import NotFoundError
from .transcripts_utils import VideoTranscriptsMixin
from .video_utils import create_youtube_string, get_video_from_cdn
@@ -607,3 +608,34 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
field_data['download_track'] = True
return field_data
+
+ def index_dictionary(self):
+ xblock_body = super(VideoDescriptor, self).index_dictionary()
+ video_body = {
+ "display_name": self.display_name,
+ }
+
+ def _update_transcript_for_index(language=None):
+ """ Find video transcript - if not found, don't update index """
+ try:
+ transcript = self.get_transcript(transcript_format='txt', lang=language)[0].replace("\n", " ")
+ transcript_index_name = "transcript_{}".format(language if language else self.transcript_language)
+ video_body.update({transcript_index_name: transcript})
+ except NotFoundError:
+ pass
+
+ if self.sub:
+ _update_transcript_for_index()
+
+ # check to see if there are transcripts in other languages besides default transcript
+ if self.transcripts:
+ for language in self.transcripts.keys():
+ _update_transcript_for_index(language)
+
+ if "content" in xblock_body:
+ xblock_body["content"].update(video_body)
+ else:
+ xblock_body["content"] = video_body
+ xblock_body["content_type"] = "Video"
+
+ return xblock_body
diff --git a/common/test/acceptance/pages/lms/courseware_search.py b/common/test/acceptance/pages/lms/courseware_search.py
new file mode 100644
index 0000000000..58cfec3b49
--- /dev/null
+++ b/common/test/acceptance/pages/lms/courseware_search.py
@@ -0,0 +1,39 @@
+"""
+Courseware search
+"""
+
+from .course_page import CoursePage
+
+
+class CoursewareSearchPage(CoursePage):
+ """
+ Coursware page featuring a search form
+ """
+
+ url_path = "courseware/"
+ search_bar_selector = '#courseware-search-bar'
+
+ @property
+ def search_results(self):
+ """ search results list showing """
+ return self.q(css='#courseware-search-results')
+
+ def is_browser_on_page(self):
+ """ did we find the search bar in the UI """
+ return self.q(css=self.search_bar_selector).present
+
+ def enter_search_term(self, text):
+ """ enter the search term into the box """
+ self.q(css=self.search_bar_selector + ' input[type="text"]').fill(text)
+
+ def search(self):
+ """ execute the search """
+ self.q(css=self.search_bar_selector + ' [type="submit"]').click()
+ self.wait_for_element_visibility('.search-info', 'Search results are shown')
+
+ def search_for_term(self, text):
+ """
+ Search and return results
+ """
+ self.enter_search_term(text)
+ self.search()
diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py
index 6db9c9cc2b..1dd1cb0f50 100644
--- a/common/test/acceptance/pages/studio/overview.py
+++ b/common/test/acceptance/pages/studio/overview.py
@@ -505,6 +505,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
"""
self.q(css=self.EXPAND_COLLAPSE_CSS).click()
+ def start_reindex(self):
+ """
+ Starts course reindex by clicking reindex button
+ """
+ self.reindex_button.click()
+
@property
def bottom_add_section_button(self):
"""
@@ -545,6 +551,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
else:
return ExpandCollapseLinkState.EXPAND
+ @property
+ def reindex_button(self):
+ """
+ Returns reindex button.
+ """
+ return self.q(css=".button.button-reindex")[0]
+
def expand_all_subsections(self):
"""
Expands all the subsections in this course.
diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py
index df86d5deca..1c611e7ba3 100644
--- a/common/test/acceptance/pages/studio/utils.py
+++ b/common/test/acceptance/pages/studio/utils.py
@@ -130,6 +130,34 @@ def add_component(page, item_type, specific_type):
page.wait_for_ajax()
+def add_html_component(page, menu_index, boilerplate=None):
+ """
+ Adds an instance of the HTML component with the specified name.
+
+ menu_index specifies which instance of the menus should be used (based on vertical
+ placement within the page).
+ """
+ # Click on the HTML icon.
+ page.wait_for_component_menu()
+ click_css(page, 'a>span.large-html-icon', menu_index, require_notification=False)
+
+ # Make sure that the menu of HTML components is visible before clicking
+ page.wait_for_element_visibility('.new-component-html', 'HTML component menu is visible')
+
+ # Now click on the component to add it.
+ component_css = 'a[data-category=html]'
+ if boilerplate:
+ component_css += '[data-boilerplate={}]'.format(boilerplate)
+ else:
+ component_css += ':not([data-boilerplate])'
+
+ page.wait_for_element_visibility(component_css, 'HTML component {} is visible'.format(boilerplate))
+
+ # Adding some components will make an ajax call but we should be OK because
+ # the click_css method is written to handle that.
+ click_css(page, component_css, 0)
+
+
@js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
diff --git a/common/test/acceptance/tests/lms/test_lms_courseware_search.py b/common/test/acceptance/tests/lms/test_lms_courseware_search.py
new file mode 100644
index 0000000000..d6a91a7ba6
--- /dev/null
+++ b/common/test/acceptance/tests/lms/test_lms_courseware_search.py
@@ -0,0 +1,194 @@
+"""
+Test courseware search
+"""
+import os
+import json
+
+from ..helpers import UniqueCourseTest
+from ...pages.common.logout import LogoutPage
+from ...pages.studio.utils import add_html_component, click_css, type_in_codemirror
+from ...pages.studio.auto_auth import AutoAuthPage
+from ...pages.studio.overview import CourseOutlinePage
+from ...pages.studio.container import ContainerPage
+from ...pages.lms.courseware_search import CoursewareSearchPage
+from ...fixtures.course import CourseFixture, XBlockFixtureDesc
+
+
+class CoursewareSearchTest(UniqueCourseTest):
+ """
+ Test courseware search.
+ """
+ USERNAME = 'STUDENT_TESTER'
+ EMAIL = 'student101@example.com'
+
+ STAFF_USERNAME = "STAFF_TESTER"
+ STAFF_EMAIL = "staff101@example.com"
+
+ HTML_CONTENT = """
+ Someday I'll wish upon a star
+ And wake up where the clouds are far
+ Behind me.
+ Where troubles melt like lemon drops
+ Away above the chimney tops
+ That's where you'll find me.
+ """
+ SEARCH_STRING = "chimney"
+ EDITED_CHAPTER_NAME = "Section 2 - edited"
+ EDITED_SEARCH_STRING = "edited"
+
+ TEST_INDEX_FILENAME = "test_root/index_file.dat"
+
+ def setUp(self):
+ """
+ Create search page and course content to search
+ """
+ # 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)
+
+ super(CoursewareSearchTest, self).setUp()
+ self.courseware_search_page = CoursewareSearchPage(self.browser, self.course_id)
+
+ self.course_outline = CourseOutlinePage(
+ self.browser,
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run']
+ )
+
+ course_fix = CourseFixture(
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run'],
+ self.course_info['display_name']
+ )
+
+ course_fix.add_children(
+ XBlockFixtureDesc('chapter', 'Section 1').add_children(
+ XBlockFixtureDesc('sequential', 'Subsection 1')
+ )
+ ).add_children(
+ XBlockFixtureDesc('chapter', 'Section 2').add_children(
+ XBlockFixtureDesc('sequential', 'Subsection 2')
+ )
+ ).install()
+
+ def tearDown(self):
+ os.remove(self.TEST_INDEX_FILENAME)
+
+ def _auto_auth(self, username, email, staff):
+ """
+ Logout and login with given credentials.
+ """
+ LogoutPage(self.browser).visit()
+ AutoAuthPage(self.browser, username=username, email=email,
+ course_id=self.course_id, staff=staff).visit()
+
+ def test_page_existence(self):
+ """
+ Make sure that the page is accessible.
+ """
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self.courseware_search_page.visit()
+
+ def _studio_publish_content(self, section_index):
+ """
+ Publish content on studio course page under specified section
+ """
+ self.course_outline.visit()
+ subsection = self.course_outline.section_at(section_index).subsection_at(0)
+ subsection.toggle_expand()
+ unit = subsection.unit_at(0)
+ unit.publish()
+
+ def _studio_edit_chapter_name(self, section_index):
+ """
+ Edit chapter name on studio course page under specified section
+ """
+ self.course_outline.visit()
+ section = self.course_outline.section_at(section_index)
+ section.change_name(self.EDITED_CHAPTER_NAME)
+
+ def _studio_add_content(self, section_index):
+ """
+ Add content on studio course page under specified section
+ """
+
+ # create a unit in course outline
+ self.course_outline.visit()
+ subsection = self.course_outline.section_at(section_index).subsection_at(0)
+ subsection.toggle_expand()
+ subsection.add_unit()
+
+ # got to unit and create an HTML component and save (not publish)
+ unit_page = ContainerPage(self.browser, None)
+ unit_page.wait_for_page()
+ add_html_component(unit_page, 0)
+ unit_page.wait_for_element_presence('.edit-button', 'Edit button is visible')
+ click_css(unit_page, '.edit-button', 0, require_notification=False)
+ unit_page.wait_for_element_visibility('.modal-editor', 'Modal editor is visible')
+ type_in_codemirror(unit_page, 0, self.HTML_CONTENT)
+ click_css(unit_page, '.action-save', 0)
+
+ def _studio_reindex(self):
+ """
+ Reindex course content on studio course page
+ """
+
+ self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+ self.course_outline.visit()
+ self.course_outline.start_reindex()
+ self.course_outline.wait_for_ajax()
+
+ def test_search(self):
+ """
+ Make sure that you can search for something.
+ """
+
+ # Create content in studio without publishing.
+ self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+ self._studio_add_content(0)
+
+ # Do a search, there should be no results shown.
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self.courseware_search_page.visit()
+ self.courseware_search_page.search_for_term(self.SEARCH_STRING)
+ assert self.SEARCH_STRING not in self.courseware_search_page.search_results.html[0]
+
+ # Publish in studio to trigger indexing.
+ self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+ self._studio_publish_content(0)
+
+ # Do the search again, this time we expect results.
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self.courseware_search_page.visit()
+ self.courseware_search_page.search_for_term(self.SEARCH_STRING)
+ assert self.SEARCH_STRING in self.courseware_search_page.search_results.html[0]
+
+ def test_reindex(self):
+ """
+ Make sure new content gets reindexed on button press.
+ """
+
+ # Create content in studio without publishing.
+ self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+ self._studio_add_content(1)
+
+ # Publish in studio to trigger indexing, and edit chapter name afterwards.
+ self._studio_publish_content(1)
+ self._studio_edit_chapter_name(1)
+
+ # Do a search, there should be no results shown.
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self.courseware_search_page.visit()
+ self.courseware_search_page.search_for_term(self.EDITED_SEARCH_STRING)
+ assert self.EDITED_SEARCH_STRING not in self.courseware_search_page.search_results.html[0]
+
+ # Do a ReIndex from studio, to add edited chapter name
+ self._studio_reindex()
+
+ # Do the search again, this time we expect results.
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self.courseware_search_page.visit()
+ self.courseware_search_page.search_for_term(self.EDITED_SEARCH_STRING)
+ assert self.EDITED_SEARCH_STRING in self.courseware_search_page.search_results.html[0]
diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py
index 60509a12bf..c87aee700a 100644
--- a/lms/envs/acceptance.py
+++ b/lms/envs/acceptance.py
@@ -179,3 +179,7 @@ XQUEUE_INTERFACE = {
YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
YOUTUBE['TEST_URL'] = "127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT)
YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT)
+
+if FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
+ # Use MockSearchEngine as the search engine for test scenario
+ SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index e0f117246d..72c26b34fb 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -500,3 +500,7 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = ENV_TOKENS.get('PDF_RECEIPT_LOGO_HEIGHT_MM', PDF_RE
PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = ENV_TOKENS.get(
'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM
)
+
+if FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
+ # Use ElasticSearch as the search engine herein
+ SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index 325812a546..9517e63e5e 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -118,6 +118,15 @@ FEATURES['ADVANCED_SECURITY'] = False
PASSWORD_MIN_LENGTH = None
PASSWORD_COMPLEXITY = {}
+# Enable courseware search for tests
+FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
+# Use MockSearchEngine as the search engine for test scenario
+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()
+
#####################################################################
# Lastly, see if the developer has any local overrides.
try:
diff --git a/lms/envs/common.py b/lms/envs/common.py
index c409c3138a..28050f4876 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -328,6 +328,9 @@ FEATURES = {
# For easily adding modes to courses during acceptance testing
'MODE_CREATION_FOR_TESTING': False,
+
+ # Courseware search feature
+ 'ENABLE_COURSEWARE_SEARCH': False,
}
# Ignore static asset files on import which match this pattern
@@ -1039,6 +1042,7 @@ courseware_js = (
for pth in ['courseware', 'histogram', 'navigation', 'time']
] +
['js/' + pth + '.js' for pth in ['ajax-error']] +
+ ['js/search/main.js'] +
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
)
@@ -2011,3 +2015,8 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = 12
PDF_RECEIPT_COBRAND_LOGO_PATH = PROJECT_ROOT + '/static/images/default-theme/logo.png'
# Height of the Co-brand Logo in mm
PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12
+
+# Use None for the default search engine
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
+# Use the LMS specific result processor
+SEARCH_RESULT_PROCESSOR = "lms.lib.courseware_search.lms_result_processor.LmsSearchResultProcessor"
diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py
index f9d4012ceb..4606d6f302 100644
--- a/lms/envs/devstack.py
+++ b/lms/envs/devstack.py
@@ -113,6 +113,11 @@ FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True
+########################## Courseware Search #######################
+FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
+SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
+
+
#####################################################################
# See if the developer has any local overrides.
try:
diff --git a/lms/envs/test.py b/lms/envs/test.py
index c26cfdeec6..43f52dc4ea 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -455,3 +455,8 @@ FEATURES['MILESTONES_APP'] = True
# ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True
+
+# Enable courseware search for tests
+FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
+# Use MockSearchEngine as the search engine for test scenario
+SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
diff --git a/lms/lib/courseware_search/__init__.py b/lms/lib/courseware_search/__init__.py
new file mode 100644
index 0000000000..8e21f75322
--- /dev/null
+++ b/lms/lib/courseware_search/__init__.py
@@ -0,0 +1,10 @@
+"""
+Search overrides for courseware search
+Implement overrides for:
+* SearchResultProcessor
+ - to mix in path to result
+ - to provide last-ditch access check
+* SearchFilterGenerator
+ - to provide additional filter fields (for cohorted values etc.)
+ - to inject specific field restrictions if/when desired
+"""
diff --git a/lms/lib/courseware_search/lms_result_processor.py b/lms/lib/courseware_search/lms_result_processor.py
new file mode 100644
index 0000000000..d9a2cec97b
--- /dev/null
+++ b/lms/lib/courseware_search/lms_result_processor.py
@@ -0,0 +1,100 @@
+"""
+This file contains implementation override of SearchResultProcessor which will allow
+ * Blends in "location" property
+ * Confirms user access to object
+"""
+from django.core.urlresolvers import reverse
+
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from search.result_processor import SearchResultProcessor
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.search import path_to_location
+
+from courseware.access import has_access
+
+
+class LmsSearchResultProcessor(SearchResultProcessor):
+
+ """ SearchResultProcessor for LMS Search """
+ _course_key = None
+ _usage_key = None
+ _module_store = None
+ _module_temp_dictionary = {}
+
+ def get_course_key(self):
+ """ fetch course key object from string representation - retain result for subsequent uses """
+ if self._course_key is None:
+ self._course_key = SlashSeparatedCourseKey.from_deprecated_string(self._results_fields["course"])
+ return self._course_key
+
+ def get_usage_key(self):
+ """ fetch usage key for component from string representation - retain result for subsequent uses """
+ if self._usage_key is None:
+ self._usage_key = self.get_course_key().make_usage_key_from_deprecated_string(self._results_fields["id"])
+ return self._usage_key
+
+ def get_module_store(self):
+ """ module store accessor - retain result for subsequent uses """
+ if self._module_store is None:
+ self._module_store = modulestore()
+ return self._module_store
+
+ def get_item(self, usage_key):
+ """ fetch item from the modulestore - don't refetch if we've already retrieved it beforehand """
+ if usage_key not in self._module_temp_dictionary:
+ self._module_temp_dictionary[usage_key] = self.get_module_store().get_item(usage_key)
+ return self._module_temp_dictionary[usage_key]
+
+ @property
+ def url(self):
+ """
+ Property to display the url for the given location, useful for allowing navigation
+ """
+ if "course" not in self._results_fields or "id" not in self._results_fields:
+ raise ValueError("Must have course and id in order to build url")
+
+ return reverse(
+ "jump_to",
+ kwargs={"course_id": self._results_fields["course"], "location": self._results_fields["id"]}
+ )
+
+ @property
+ def location(self):
+ """
+ Blend "location" property into the resultset, so that the path to the found component can be shown within the UI
+ """
+ # TODO: update whern changes to "cohorted-courseware" branch are merged in
+ (course_key, chapter, section, position) = path_to_location(self.get_module_store(), self.get_usage_key())
+
+ def get_display_name(category, item_id):
+ """ helper to get display name from object """
+ item = self.get_item(course_key.make_usage_key(category, item_id))
+ return getattr(item, "display_name", None)
+
+ def get_position_name(section, position):
+ """ helper to fetch name corresponding to the position therein """
+ pos = int(position)
+ section_item = self.get_item(course_key.make_usage_key("sequential", section))
+ if section_item.has_children and len(section_item.children) >= pos:
+ item = self.get_item(section_item.children[pos - 1])
+ return getattr(item, "display_name", None)
+ return None
+
+ location_description = []
+ if chapter:
+ location_description.append(get_display_name("chapter", chapter))
+ if section:
+ location_description.append(get_display_name("sequential", section))
+ if position:
+ location_description.append(get_position_name(section, position))
+
+ return location_description
+
+ def should_remove(self, user):
+ """ Test to see if this result should be removed due to access restriction """
+ return not has_access(
+ user,
+ "load",
+ self.get_item(self.get_usage_key()),
+ self.get_course_key()
+ )
diff --git a/lms/lib/courseware_search/test/test_lms_result_processor.py b/lms/lib/courseware_search/test/test_lms_result_processor.py
new file mode 100644
index 0000000000..0f2a77e7e0
--- /dev/null
+++ b/lms/lib/courseware_search/test/test_lms_result_processor.py
@@ -0,0 +1,138 @@
+"""
+Tests for the lms_result_processor
+"""
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+
+from courseware.tests.factories import UserFactory
+
+from lms.lib.courseware_search.lms_result_processor import LmsSearchResultProcessor
+
+
+class LmsSearchResultProcessorTestCase(ModuleStoreTestCase):
+ """ Test case class to test search result processor """
+
+ def build_course(self):
+ """
+ Build up a course tree with an html control
+ """
+ self.global_staff = UserFactory(is_staff=True)
+
+ self.course = CourseFactory.create(
+ org='Elasticsearch',
+ course='ES101',
+ run='test_run',
+ display_name='Elasticsearch test course',
+ )
+ self.section = ItemFactory.create(
+ parent=self.course,
+ category='chapter',
+ display_name='Test Section',
+ )
+ self.subsection = ItemFactory.create(
+ parent=self.section,
+ category='sequential',
+ display_name='Test Subsection',
+ )
+ self.vertical = ItemFactory.create(
+ parent=self.subsection,
+ category='vertical',
+ display_name='Test Unit',
+ )
+ self.html = ItemFactory.create(
+ parent=self.vertical, category='html',
+ display_name='Test Html control',
+ )
+
+ def setUp(self):
+ # from nose.tools import set_trace
+ # set_trace()
+ self.build_course()
+
+ def test_url_parameter(self):
+ fake_url = ""
+ srp = LmsSearchResultProcessor({}, "test")
+ with self.assertRaises(ValueError):
+ fake_url = srp.url
+ self.assertEqual(fake_url, "")
+
+ srp = LmsSearchResultProcessor(
+ {
+ "course": unicode(self.course.id),
+ "id": unicode(self.html.scope_ids.usage_id),
+ "content": {"text": "This is the html text"}
+ },
+ "test"
+ )
+
+ self.assertEqual(
+ srp.url, "/courses/{}/jump_to/{}".format(unicode(self.course.id), unicode(self.html.scope_ids.usage_id)))
+
+ def test_location_parameter(self):
+ srp = LmsSearchResultProcessor(
+ {
+ "course": unicode(self.course.id),
+ "id": unicode(self.html.scope_ids.usage_id),
+ "content": {"text": "This is html test text"}
+ },
+ "test"
+ )
+
+ self.assertEqual(len(srp.location), 3)
+ self.assertEqual(srp.location[0], 'Test Section')
+ self.assertEqual(srp.location[1], 'Test Subsection')
+ self.assertEqual(srp.location[2], 'Test Unit')
+
+ srp = LmsSearchResultProcessor(
+ {
+ "course": unicode(self.course.id),
+ "id": unicode(self.vertical.scope_ids.usage_id),
+ "content": {"text": "This is html test text"}
+ },
+ "test"
+ )
+
+ self.assertEqual(len(srp.location), 3)
+ self.assertEqual(srp.location[0], 'Test Section')
+ self.assertEqual(srp.location[1], 'Test Subsection')
+ self.assertEqual(srp.location[2], 'Test Unit')
+
+ srp = LmsSearchResultProcessor(
+ {
+ "course": unicode(self.course.id),
+ "id": unicode(self.subsection.scope_ids.usage_id),
+ "content": {"text": "This is html test text"}
+ },
+ "test"
+ )
+
+ self.assertEqual(len(srp.location), 2)
+ self.assertEqual(srp.location[0], 'Test Section')
+ self.assertEqual(srp.location[1], 'Test Subsection')
+
+ srp = LmsSearchResultProcessor(
+ {
+ "course": unicode(self.course.id),
+ "id": unicode(self.section.scope_ids.usage_id),
+ "content": {"text": "This is html test text"}
+ },
+ "test"
+ )
+
+ self.assertEqual(len(srp.location), 1)
+ self.assertEqual(srp.location[0], 'Test Section')
+
+ def test_should_remove(self):
+ """
+ Tests that "visible_to_staff_only" overrides start date.
+ """
+ srp = LmsSearchResultProcessor(
+ {
+ "course": unicode(self.course.id),
+ "id": unicode(self.html.scope_ids.usage_id),
+ "content": {"text": "This is html test text"}
+ },
+ "test"
+ )
+
+ self.assertEqual(srp.should_remove(self.global_staff), False)
diff --git a/lms/static/js/fixtures/search_form.html b/lms/static/js/fixtures/search_form.html
new file mode 100644
index 0000000000..77c9a1dad8
--- /dev/null
+++ b/lms/static/js/fixtures/search_form.html
@@ -0,0 +1,7 @@
+