Commit e09d47b1 by Davorin Sego Committed by Martyn James

Courseware Search integration

parent a9127c18
...@@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _ ...@@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _
import django.utils import django.utils
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.conf import settings from django.conf import settings
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods, require_GET
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404 from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404
...@@ -22,6 +22,7 @@ from edxmako.shortcuts import render_to_response ...@@ -22,6 +22,7 @@ from edxmako.shortcuts import render_to_response
from xmodule.course_module import DEFAULT_START_DATE from xmodule.course_module import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.tabs import PDFTextbookTabs from xmodule.tabs import PDFTextbookTabs
from xmodule.partitions.partitions import UserPartition from xmodule.partitions.partitions import UserPartition
...@@ -75,6 +76,7 @@ from course_action_state.managers import CourseActionStateItemNotFoundError ...@@ -75,6 +76,7 @@ from course_action_state.managers import CourseActionStateItemNotFoundError
from microsite_configuration import microsite from microsite_configuration import microsite
from xmodule.course_module import CourseFields from xmodule.course_module import CourseFields
from xmodule.split_test_module import get_split_user_partitions from xmodule.split_test_module import get_split_user_partitions
from student.auth import has_course_author_access
from util.milestones_helpers import ( from util.milestones_helpers import (
set_prerequisite_courses, set_prerequisite_courses,
...@@ -90,7 +92,7 @@ CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can ...@@ -90,7 +92,7 @@ CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can
CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration' CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration'
__all__ = ['course_info_handler', 'course_handler', 'course_listing', __all__ = ['course_info_handler', 'course_handler', 'course_listing',
'course_info_update_handler', 'course_info_update_handler', 'course_search_index_handler',
'course_rerun_handler', 'course_rerun_handler',
'settings_handler', 'settings_handler',
'grading_handler', 'grading_handler',
...@@ -121,6 +123,15 @@ def get_course_and_check_access(course_key, user, depth=0): ...@@ -121,6 +123,15 @@ def get_course_and_check_access(course_key, user, depth=0):
return course_module return course_module
def reindex_course_and_check_access(course_key, user):
"""
Internal method used to restart indexing on a course.
"""
if not has_course_author_access(user, course_key):
raise PermissionDenied()
return CoursewareSearchIndexer.do_course_reindex(modulestore(), course_key)
@login_required @login_required
def course_notifications_handler(request, course_key_string=None, action_state_id=None): def course_notifications_handler(request, course_key_string=None, action_state_id=None):
""" """
...@@ -283,6 +294,28 @@ def course_rerun_handler(request, course_key_string): ...@@ -283,6 +294,28 @@ def course_rerun_handler(request, course_key_string):
}) })
@login_required
@ensure_csrf_cookie
@require_GET
def course_search_index_handler(request, course_key_string):
"""
The restful handler for course indexing.
GET
html: return status of indexing task
"""
# Only global staff (PMs) are able to index courses
if not GlobalStaff().has_user(request.user):
raise PermissionDenied()
course_key = CourseKey.from_string(course_key_string)
with modulestore().bulk_operations(course_key):
try:
reindex_course_and_check_access(course_key, request.user)
except SearchIndexingError as search_err:
return HttpResponse(search_err.error_list, status=500)
return HttpResponse({}, status=200)
def _course_outline_json(request, course_module): def _course_outline_json(request, course_module):
""" """
Returns a JSON representation of the course module and recursively all of its children. Returns a JSON representation of the course module and recursively all of its children.
......
...@@ -316,3 +316,7 @@ API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT) ...@@ -316,3 +316,7 @@ API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT)
# Video Caching. Pairing country codes with CDN URLs. # Video Caching. Pairing country codes with CDN URLs.
# Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='} # Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='}
VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {}) VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {})
if FEATURES['ENABLE_COURSEWARE_INDEX']:
# Use ElasticSearch for the search engine
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
...@@ -77,6 +77,13 @@ YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT) ...@@ -77,6 +77,13 @@ 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['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) YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT)
FEATURES['ENABLE_COURSEWARE_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()
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:
......
...@@ -146,6 +146,9 @@ FEATURES = { ...@@ -146,6 +146,9 @@ FEATURES = {
# Toggle course entrance exams feature # Toggle course entrance exams feature
'ENTRANCE_EXAMS': False, 'ENTRANCE_EXAMS': False,
# Enable the courseware search functionality
'ENABLE_COURSEWARE_INDEX': False,
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
...@@ -868,3 +871,11 @@ FILES_AND_UPLOAD_TYPE_FILTERS = { ...@@ -868,3 +871,11 @@ FILES_AND_UPLOAD_TYPE_FILTERS = {
'application/vnd.ms-powerpoint', 'application/vnd.ms-powerpoint',
], ],
} }
# Default to no Search Engine
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
ELASTIC_FIELD_MAPPINGS = {
"start_date": {
"type": "date"
}
}
...@@ -83,6 +83,9 @@ FEATURES['MILESTONES_APP'] = True ...@@ -83,6 +83,9 @@ FEATURES['MILESTONES_APP'] = True
################################ ENTRANCE EXAMS ################################ ################################ ENTRANCE EXAMS ################################
FEATURES['ENTRANCE_EXAMS'] = True FEATURES['ENTRANCE_EXAMS'] = True
################################ SEARCH INDEX ################################
FEATURES['ENABLE_COURSEWARE_INDEX'] = True
SEARCH_ENGINE = "search.elastic.ElasticSearchEngine"
############################################################################### ###############################################################################
# See if the developer has any local overrides. # See if the developer has any local overrides.
......
...@@ -254,3 +254,11 @@ ENTRANCE_EXAM_MIN_SCORE_PCT = 50 ...@@ -254,3 +254,11 @@ ENTRANCE_EXAM_MIN_SCORE_PCT = 50
VIDEO_CDN_URL = { VIDEO_CDN_URL = {
'CN': 'http://api.xuetangx.com/edx/video?s3_url=' 'CN': 'http://api.xuetangx.com/edx/video?s3_url='
} }
# Courseware Search Index
FEATURES['ENABLE_COURSEWARE_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()
...@@ -8,7 +8,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils", ...@@ -8,7 +8,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils",
getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState,
collapseItemsAndVerifyState, createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON, collapseItemsAndVerifyState, createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON,
verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON,
createMockVerticalJSON, createMockVerticalJSON, createMockIndexJSON,
mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'), mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'),
mockRerunNotification = readFixtures('mock/mock-course-rerun-notification.underscore'); mockRerunNotification = readFixtures('mock/mock-course-rerun-notification.underscore');
...@@ -88,6 +88,21 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils", ...@@ -88,6 +88,21 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils",
}, options); }, options);
}; };
createMockIndexJSON = function(option) {
if(option){
return {
status: 200,
responseText: ''
};
}
else {
return {
status: 500,
responseText: JSON.stringify('Could not index item: course/slashes:mock+item')
};
}
};
getItemsOfType = function(type) { getItemsOfType = function(type) {
return outlinePage.$('.outline-' + type); return outlinePage.$('.outline-' + type);
}; };
...@@ -308,6 +323,28 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils", ...@@ -308,6 +323,28 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils",
outlinePage.$('.nav-actions .button-toggle-expand-collapse .expand-all').click(); outlinePage.$('.nav-actions .button-toggle-expand-collapse .expand-all').click();
verifyItemsExpanded('section', true); verifyItemsExpanded('section', true);
}); });
it('can start reindex of a course - respond success', function() {
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
var reindexSpy = spyOn(outlinePage, 'startReIndex').andCallThrough();
var successSpy = spyOn(outlinePage, 'onIndexSuccess').andCallThrough();
var reindexButton = outlinePage.$('.button.button-reindex');
reindexButton.trigger('click');
AjaxHelpers.expectJsonRequest(requests, 'GET', '/course_search_index/5');
AjaxHelpers.respondWithJson(requests, createMockIndexJSON(true));
expect(reindexSpy).toHaveBeenCalled();
expect(successSpy).toHaveBeenCalled();
});
it('can start reindex of a course - respond fail', function() {
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
var reindexSpy = spyOn(outlinePage, 'startReIndex').andCallThrough();
var reindexButton = outlinePage.$('.button.button-reindex');
reindexButton.trigger('click');
AjaxHelpers.expectJsonRequest(requests, 'GET', '/course_search_index/5');
AjaxHelpers.respondWithJson(requests, createMockIndexJSON(false));
expect(reindexSpy).toHaveBeenCalled();
});
}); });
describe("Empty course", function() { describe("Empty course", function() {
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
* This page is used to show the user an outline of the course. * This page is used to show the user an outline of the course.
*/ */
define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/xblock_utils", define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/xblock_utils",
"js/views/course_outline", "js/views/utils/view_utils"], "js/views/course_outline", "js/views/utils/view_utils", "js/views/feedback_alert"],
function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils) { function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, AlertView) {
var expandedLocators, CourseOutlinePage; var expandedLocators, CourseOutlinePage;
CourseOutlinePage = BasePage.extend({ CourseOutlinePage = BasePage.extend({
...@@ -24,6 +24,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -24,6 +24,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
this.$('.button-new').click(function(event) { this.$('.button-new').click(function(event) {
self.outlineView.handleAddEvent(event); self.outlineView.handleAddEvent(event);
}); });
this.$('.button.button-reindex').click(function(event) {
self.handleReIndexEvent(event);
});
this.model.on('change', this.setCollapseExpandVisibility, this); this.model.on('change', this.setCollapseExpandVisibility, this);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
$('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden'); $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden');
...@@ -100,6 +103,32 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ...@@ -100,6 +103,32 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
} }
}, this); }, this);
} }
},
handleReIndexEvent: function(event) {
var self = this;
event.preventDefault();
var target = $(event.currentTarget);
target.css('cursor', 'wait');
this.startReIndex()
.done(function() {self.onIndexSuccess();})
.always(function() {target.css('cursor', 'pointer');});
},
startReIndex: function() {
var locator = window.course.id;
return $.ajax({
url: '/course_search_index/' + locator,
method: 'GET'
});
},
onIndexSuccess: function() {
var msg = new AlertView.Announcement({
title: gettext('Course Index'),
message: gettext('Course has been successfully reindexed.')
});
msg.show();
} }
}); });
......
...@@ -69,6 +69,11 @@ from contentstore.utils import reverse_usage_url ...@@ -69,6 +69,11 @@ from contentstore.utils import reverse_usage_url
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="button button-reindex" data-category="reindex" title="${_('Reindex current course')}">
<i class="icon-arrow-right"></i>${_('Reindex')}
</a>
</li>
<li class="nav-item">
<a href="#" class="button button-toggle button-toggle-expand-collapse collapse-all is-hidden"> <a href="#" class="button button-toggle button-toggle-expand-collapse collapse-all is-hidden">
<span class="collapse-all"><i class="icon fa fa-arrow-up"></i> <span class="label">${_("Collapse All Sections")}</span></span> <span class="collapse-all"><i class="icon fa fa-arrow-up"></i> <span class="label">${_("Collapse All Sections")}</span></span>
<span class="expand-all"><i class="icon fa fa-arrow-down"></i> <span class="label">${_("Expand All Sections")}</span></span> <span class="expand-all"><i class="icon fa fa-arrow-down"></i> <span class="label">${_("Expand All Sections")}</span></span>
......
...@@ -22,6 +22,11 @@ ...@@ -22,6 +22,11 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a title="Reindex current course" data-category="reindex" class="button button-reindex" href="#">
<i class="icon-arrow-right"></i>Reindex
</a>
</li>
<li class="nav-item">
<a href="#" rel="external" class="button view-button view-live-button" title="Click to open the courseware in the LMS in a new tab">View Live</a> <a href="#" rel="external" class="button view-button view-live-button" title="Click to open the courseware in the LMS in a new tab">View Live</a>
</li> </li>
</ul> </ul>
......
...@@ -80,6 +80,11 @@ urlpatterns += patterns( ...@@ -80,6 +80,11 @@ urlpatterns += patterns(
'course_info_update_handler' 'course_info_update_handler'
), ),
url(r'^home/$', 'course_listing', name='home'), 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/{}?$'.format(settings.COURSE_KEY_PATTERN), 'course_handler', name='course_handler'),
url(r'^course_notifications/{}/(?P<action_state_id>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'course_notifications_handler'), url(r'^course_notifications/{}/(?P<action_state_id>\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'), url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
......
...@@ -17,7 +17,8 @@ import textwrap ...@@ -17,7 +17,8 @@ import textwrap
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xblock.core import XBlock from xblock.core import XBlock
from xmodule.edxnotes_utils import edxnotes from xmodule.edxnotes_utils import edxnotes
from xmodule.annotator_mixin import html_to_text
import re
log = logging.getLogger("edx.courseware") log = logging.getLogger("edx.courseware")
...@@ -253,6 +254,25 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): ...@@ -253,6 +254,25 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
non_editable_fields.append(HtmlDescriptor.use_latex_compiler) non_editable_fields.append(HtmlDescriptor.use_latex_compiler)
return non_editable_fields 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|&nbsp;|//)+", " ", html_to_text(self.data))
# Removing HTML CDATA
html_content = re.sub(r"<!\[CDATA\[.*\]\]>", "", 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): class AboutFields(object):
display_name = String( display_name = String(
......
""" 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)
...@@ -278,7 +278,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): ...@@ -278,7 +278,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
raw_metadata = json_data.get('metadata', {}) raw_metadata = json_data.get('metadata', {})
# published_on was previously stored as a list of time components instead of a datetime # published_on was previously stored as a list of time components instead of a datetime
if raw_metadata.get('published_date'): 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') module._edit_info['published_by'] = raw_metadata.get('published_by')
# decache any computed pending field settings # decache any computed pending field settings
...@@ -1804,3 +1806,25 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -1804,3 +1806,25 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
# To allow prioritizing draft vs published material # To allow prioritizing draft vs published material
self.collection.create_index('_id.revision') 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()
...@@ -12,6 +12,7 @@ import logging ...@@ -12,6 +12,7 @@ import logging
from opaque_keys.edx.locations import Location from opaque_keys.edx.locations import Location
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
from xmodule.modulestore.exceptions import ( from xmodule.modulestore.exceptions import (
ItemNotFoundError, DuplicateItemError, DuplicateCourseError, InvalidBranchSetting ItemNotFoundError, DuplicateItemError, DuplicateCourseError, InvalidBranchSetting
) )
...@@ -509,7 +510,8 @@ class DraftModuleStore(MongoModuleStore): ...@@ -509,7 +510,8 @@ class DraftModuleStore(MongoModuleStore):
parent_locations = [draft_parent.location] parent_locations = [draft_parent.location]
# there could be 2 parents if # there could be 2 parents if
# Case 1: the draft item moved from one parent to another # 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: for parent_location in parent_locations:
# don't remove from direct_only parent if other versions of this still exists (this code # 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) # assumes that there's only one parent_location in this case)
...@@ -541,6 +543,10 @@ class DraftModuleStore(MongoModuleStore): ...@@ -541,6 +543,10 @@ class DraftModuleStore(MongoModuleStore):
) )
self._delete_subtree(location, as_functions) 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): def _delete_subtree(self, location, as_functions, draft_only=False):
""" """
Internal method for deleting all of the subtree whose revisions match the as_functions Internal method for deleting all of the subtree whose revisions match the as_functions
...@@ -713,6 +719,10 @@ class DraftModuleStore(MongoModuleStore): ...@@ -713,6 +719,10 @@ class DraftModuleStore(MongoModuleStore):
bulk_record = self._get_bulk_ops_record(location.course_key) bulk_record = self._get_bulk_ops_record(location.course_key)
bulk_record.dirty = True bulk_record.dirty = True
self.collection.remove({'_id': {'$in': to_be_deleted}}) 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)) return self.get_item(as_published(location))
def unpublish(self, location, user_id, **kwargs): def unpublish(self, location, user_id, **kwargs):
......
...@@ -5,6 +5,7 @@ Module for the dual-branch fall-back Draft->Published Versioning ModuleStore ...@@ -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.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.exceptions import InvalidVersionError from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.courseware_index import CoursewareSearchIndexer
from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
from xmodule.modulestore.draft_and_published import ( from xmodule.modulestore.draft_and_published import (
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
...@@ -203,6 +204,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli ...@@ -203,6 +204,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
if branch == ModuleStoreEnum.BranchName.draft and branched_location.block_type in DIRECT_ONLY_CATEGORIES: 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) 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): def _map_revision_to_branch(self, key, revision=None):
""" """
Maps RevisionOptions to BranchNames, inserting them into the key Maps RevisionOptions to BranchNames, inserting them into the key
...@@ -345,6 +350,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli ...@@ -345,6 +350,10 @@ class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPubli
[location], [location],
blacklist=blacklist 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) return self.get_item(location.for_branch(ModuleStoreEnum.BranchName.published), **kwargs)
def unpublish(self, location, user_id, **kwargs): def unpublish(self, location, user_id, **kwargs):
......
...@@ -188,3 +188,22 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor): ...@@ -188,3 +188,22 @@ class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
for child in self.get_children(): for child in self.get_children():
self.runtime.add_block_as_child_node(child, xml_object) self.runtime.add_block_as_child_node(child, xml_object)
return 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
...@@ -3,9 +3,25 @@ import unittest ...@@ -3,9 +3,25 @@ import unittest
from mock import Mock from mock import Mock
from xblock.field_data import DictFieldData 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): class HtmlModuleSubstitutionTestCase(unittest.TestCase):
...@@ -36,3 +52,71 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase): ...@@ -36,3 +52,71 @@ class HtmlModuleSubstitutionTestCase(unittest.TestCase):
module_system.anonymous_student_id = None module_system.anonymous_student_id = None
module = HtmlModule(self.descriptor, module_system, field_data, Mock()) module = HtmlModule(self.descriptor, module_system, field_data, Mock())
self.assertEqual(module.get_html(), sample_xml) 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 = '''
<html>
<p>Hello World!</p>
</html>
'''
descriptor = instantiate_descriptor(data=sample_xml)
self.assertEqual(descriptor.index_dictionary(), {
"content": {"html_content": " Hello World! ", "display_name": "Text"},
"content_type": "HTML Content"
})
sample_xml_cdata = '''
<html>
<p>This has CDATA in it.</p>
<![CDATA[This is just a CDATA!]]>
</html>
'''
descriptor = instantiate_descriptor(data=sample_xml_cdata)
self.assertEqual(descriptor.index_dictionary(), {
"content": {"html_content": " This has CDATA in it. ", "display_name": "Text"},
"content_type": "HTML Content"
})
sample_xml_tab_spaces = '''
<html>
<p> Text has spaces :) </p>
</html>
'''
descriptor = instantiate_descriptor(data=sample_xml_tab_spaces)
self.assertEqual(descriptor.index_dictionary(), {
"content": {"html_content": " Text has spaces :) ", "display_name": "Text"},
"content_type": "HTML Content"
})
sample_xml_comment = '''
<html>
<p>This has HTML comment in it.</p>
<!-- Html Comment -->
</html>
'''
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 = '''
<html>
<!-- Beginning of the html -->
<p>This has HTML comment in it.<!-- Commenting Content --></p>
<!-- Here comes CDATA -->
<![CDATA[This is just a CDATA!]]>
<p>HTML end.</p>
</html>
'''
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"
})
...@@ -32,6 +32,7 @@ from xmodule.x_module import XModule, module_attr ...@@ -32,6 +32,7 @@ from xmodule.x_module import XModule, module_attr
from xmodule.editing_module import TabsEditingDescriptor from xmodule.editing_module import TabsEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_field
from xmodule.exceptions import NotFoundError
from .transcripts_utils import VideoTranscriptsMixin from .transcripts_utils import VideoTranscriptsMixin
from .video_utils import create_youtube_string, get_video_from_cdn from .video_utils import create_youtube_string, get_video_from_cdn
...@@ -607,3 +608,34 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -607,3 +608,34 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
field_data['download_track'] = True field_data['download_track'] = True
return field_data 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
"""
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()
...@@ -505,6 +505,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -505,6 +505,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
""" """
self.q(css=self.EXPAND_COLLAPSE_CSS).click() self.q(css=self.EXPAND_COLLAPSE_CSS).click()
def start_reindex(self):
"""
Starts course reindex by clicking reindex button
"""
self.reindex_button.click()
@property @property
def bottom_add_section_button(self): def bottom_add_section_button(self):
""" """
...@@ -545,6 +551,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): ...@@ -545,6 +551,13 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
else: else:
return ExpandCollapseLinkState.EXPAND return ExpandCollapseLinkState.EXPAND
@property
def reindex_button(self):
"""
Returns reindex button.
"""
return self.q(css=".button.button-reindex")[0]
def expand_all_subsections(self): def expand_all_subsections(self):
""" """
Expands all the subsections in this course. Expands all the subsections in this course.
......
...@@ -130,6 +130,34 @@ def add_component(page, item_type, specific_type): ...@@ -130,6 +130,34 @@ def add_component(page, item_type, specific_type):
page.wait_for_ajax() 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') @js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"): def type_in_codemirror(page, index, text, find_prefix="$"):
script = """ script = """
......
"""
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]
...@@ -179,3 +179,7 @@ XQUEUE_INTERFACE = { ...@@ -179,3 +179,7 @@ XQUEUE_INTERFACE = {
YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT) 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['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) 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"
...@@ -500,3 +500,7 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = ENV_TOKENS.get('PDF_RECEIPT_LOGO_HEIGHT_MM', PDF_RE ...@@ -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 = ENV_TOKENS.get(
'PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM', PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM '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"
...@@ -118,6 +118,15 @@ FEATURES['ADVANCED_SECURITY'] = False ...@@ -118,6 +118,15 @@ FEATURES['ADVANCED_SECURITY'] = False
PASSWORD_MIN_LENGTH = None PASSWORD_MIN_LENGTH = None
PASSWORD_COMPLEXITY = {} 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. # Lastly, see if the developer has any local overrides.
try: try:
......
...@@ -328,6 +328,9 @@ FEATURES = { ...@@ -328,6 +328,9 @@ FEATURES = {
# For easily adding modes to courses during acceptance testing # For easily adding modes to courses during acceptance testing
'MODE_CREATION_FOR_TESTING': False, 'MODE_CREATION_FOR_TESTING': False,
# Courseware search feature
'ENABLE_COURSEWARE_SEARCH': False,
} }
# Ignore static asset files on import which match this pattern # Ignore static asset files on import which match this pattern
...@@ -1039,6 +1042,7 @@ courseware_js = ( ...@@ -1039,6 +1042,7 @@ courseware_js = (
for pth in ['courseware', 'histogram', 'navigation', 'time'] for pth in ['courseware', 'histogram', 'navigation', 'time']
] + ] +
['js/' + pth + '.js' for pth in ['ajax-error']] + ['js/' + pth + '.js' for pth in ['ajax-error']] +
['js/search/main.js'] +
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js')) sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
) )
...@@ -2011,3 +2015,8 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = 12 ...@@ -2011,3 +2015,8 @@ PDF_RECEIPT_LOGO_HEIGHT_MM = 12
PDF_RECEIPT_COBRAND_LOGO_PATH = PROJECT_ROOT + '/static/images/default-theme/logo.png' PDF_RECEIPT_COBRAND_LOGO_PATH = PROJECT_ROOT + '/static/images/default-theme/logo.png'
# Height of the Co-brand Logo in mm # Height of the Co-brand Logo in mm
PDF_RECEIPT_COBRAND_LOGO_HEIGHT_MM = 12 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"
...@@ -113,6 +113,11 @@ FEATURES['MILESTONES_APP'] = True ...@@ -113,6 +113,11 @@ FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = 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. # See if the developer has any local overrides.
try: try:
......
...@@ -455,3 +455,8 @@ FEATURES['MILESTONES_APP'] = True ...@@ -455,3 +455,8 @@ FEATURES['MILESTONES_APP'] = True
# ENTRANCE EXAMS # ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True 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"
"""
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
"""
"""
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()
)
"""
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)
<div id="courseware-search-bar" class="search-container">
<form role="search-form">
<input type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="Search">search <i class="icon-search"></i></button>
<button type="button" class="cancel-button" aria-label="Cancel"><i class="icon-remove"></i></button>
</form>
</div>
;(function (define) {
define([
'backbone',
'js/search/models/search_result'
], function (Backbone, SearchResult) {
'use strict';
return Backbone.Collection.extend({
model: SearchResult,
pageSize: 20,
totalCount: 0,
accessDeniedCount: 0,
searchTerm: '',
page: 0,
url: '/search/',
fetchXhr: null,
initialize: function (models, options) {
// call super constructor
Backbone.Collection.prototype.initialize.apply(this, arguments);
if (options && options.course_id) {
this.url += options.course_id;
}
},
performSearch: function (searchTerm) {
this.fetchXhr && this.fetchXhr.abort();
this.searchTerm = searchTerm || '';
this.totalCount = 0;
this.accessDeniedCount = 0;
this.page = 0;
this.fetchXhr = this.fetch({
data: {
search_string: searchTerm,
page_size: this.pageSize,
page_index: 0
},
type: 'POST',
success: function (self, xhr) {
self.trigger('search');
},
error: function (self, xhr) {
self.trigger('error');
}
});
},
loadNextPage: function () {
this.fetchXhr && this.fetchXhr.abort();
this.fetchXhr = this.fetch({
data: {
search_string: this.searchTerm,
page_size: this.pageSize,
page_index: this.page + 1
},
type: 'POST',
success: function (self, xhr) {
self.page += 1;
self.trigger('next');
},
error: function (self, xhr) {
self.trigger('error');
}
});
},
cancelSearch: function () {
this.fetchXhr && this.fetchXhr.abort();
this.page = 0;
this.totalCount = 0;
this.accessDeniedCount = 0;
},
parse: function(response) {
this.totalCount = response.total;
this.accessDeniedCount += response.access_denied_count;
this.totalCount -= this.accessDeniedCount;
return _.map(response.results, function(result){ return result.data; });
},
hasNextPage: function () {
return this.totalCount - ((this.page + 1) * this.pageSize) > 0;
}
});
});
})(define || RequireJS.define);
RequireJS.require([
'jquery',
'backbone',
'js/search/search_app'
], function ($, Backbone, SearchApp) {
'use strict';
var course_id = $('#courseware-search-results').attr('data-course-id');
var app = new SearchApp(course_id);
Backbone.history.start();
});
;(function (define) {
define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Model.extend({
defaults: {
location: [],
content_type: '',
excerpt: '',
url: ''
}
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'backbone',
'js/search/search_router',
'js/search/views/search_form',
'js/search/views/search_list_view',
'js/search/collections/search_collection'
], function(Backbone, SearchRouter, SearchForm, SearchListView, SearchCollection) {
'use strict';
return function (course_id) {
var self = this;
this.router = new SearchRouter();
this.form = new SearchForm();
this.collection = new SearchCollection([], { course_id: course_id });
this.results = new SearchListView({ collection: this.collection });
this.form.on('search', this.results.showLoadingMessage, this.results);
this.form.on('search', this.collection.performSearch, this.collection);
this.form.on('search', function (term) {
self.router.navigate('search/' + term, { replace: true });
});
this.form.on('clear', this.collection.cancelSearch, this.collection);
this.form.on('clear', this.results.clear, this.results);
this.form.on('clear', this.router.navigate, this.router);
this.results.on('next', this.collection.loadNextPage, this.collection);
this.router.on('route:search', this.form.doSearch, this.form);
};
});
})(define || RequireJS.define);
;(function (define) {
define(['backbone'], function (Backbone) {
'use strict';
return Backbone.Router.extend({
routes: {
'search/:query': 'search'
}
});
});
})(define || RequireJS.define);
;(function (define) {
define(['jquery', 'backbone'], function ($, Backbone) {
'use strict';
return Backbone.View.extend({
el: '#courseware-search-bar',
events: {
'submit form': 'submitForm',
'click .cancel-button': 'clearSearch',
},
initialize: function () {
this.$searchField = this.$el.find('.search-field');
this.$searchButton = this.$el.find('.search-button');
this.$cancelButton = this.$el.find('.cancel-button');
},
submitForm: function (event) {
event.preventDefault();
this.doSearch();
},
doSearch: function (term) {
if (term) {
this.$searchField.val(term);
}
else {
term = this.$searchField.val();
}
var trimmed = $.trim(term);
if (trimmed) {
this.setActiveStyle();
this.trigger('search', trimmed);
}
else {
this.clearSearch();
}
},
clearSearch: function () {
this.$searchField.val('');
this.setInitialStyle();
this.trigger('clear');
},
setActiveStyle: function () {
this.$searchField.addClass('is-active');
this.$searchButton.hide();
this.$cancelButton.show();
},
setInitialStyle: function () {
this.$searchField.removeClass('is-active');
this.$searchButton.show();
this.$cancelButton.hide();
}
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'jquery',
'underscore',
'backbone',
'gettext'
], function ($, _, Backbone, gettext) {
'use strict';
return Backbone.View.extend({
tagName: 'li',
className: 'search-results-item',
attributes: {
'role': 'region',
'aria-label': 'search result'
},
initialize: function () {
this.tpl = _.template($('#search_item-tpl').html());
},
render: function () {
this.$el.html(this.tpl(this.model.attributes));
return this;
}
});
});
})(define || RequireJS.define);
;(function (define) {
define([
'jquery',
'underscore',
'backbone',
'gettext',
'js/search/views/search_item_view'
], function ($, _, Backbone, gettext, SearchItemView) {
'use strict';
return Backbone.View.extend({
el: '#courseware-search-results',
events: {
'click .search-load-next': 'loadNext'
},
spinner: '.icon',
initialize: function () {
this.courseName = this.$el.attr('data-course-name');
this.$courseContent = $('#course-content');
this.listTemplate = _.template($('#search_list-tpl').html());
this.loadingTemplate = _.template($('#search_loading-tpl').html());
this.errorTemplate = _.template($('#search_error-tpl').html());
this.collection.on('search', this.render, this);
this.collection.on('next', this.renderNext, this);
this.collection.on('error', this.showErrorMessage, this);
},
render: function () {
this.$el.html(this.listTemplate({
courseName: this.courseName,
totalCount: this.collection.totalCount,
totalCountMsg: this.totalCountMsg(),
pageSize: this.collection.pageSize,
hasMoreResults: this.collection.hasNextPage()
}));
this.renderItems();
this.$courseContent.hide();
this.$el.show();
return this;
},
renderNext: function () {
// total count may have changed
this.$el.find('.search-count').text(this.totalCountMsg());
this.renderItems();
if (! this.collection.hasNextPage()) {
this.$el.find('.search-load-next').remove();
}
this.$el.find(this.spinner).hide();
},
renderItems: function () {
var items = this.collection.map(function (result) {
var item = new SearchItemView({ model: result });
return item.render().el;
});
this.$el.find('.search-results').append(items);
},
totalCountMsg: function () {
var fmt = ngettext('%s result', '%s results', this.collection.totalCount);
return interpolate(fmt, [this.collection.totalCount]);
},
clear: function () {
this.$el.hide().empty();
this.$courseContent.show();
},
showLoadingMessage: function () {
this.$el.html(this.loadingTemplate());
this.$el.show();
this.$courseContent.hide();
},
showErrorMessage: function () {
this.$el.html(this.errorTemplate());
this.$el.show();
this.$courseContent.hide();
},
loadNext: function (event) {
event && event.preventDefault();
this.$el.find(this.spinner).show();
this.trigger('next');
}
});
});
})(define || RequireJS.define);
...@@ -560,7 +560,8 @@ ...@@ -560,7 +560,8 @@
'lms/include/js/spec/edxnotes/models/note_spec.js', 'lms/include/js/spec/edxnotes/models/note_spec.js',
'lms/include/js/spec/edxnotes/plugins/events_spec.js', 'lms/include/js/spec/edxnotes/plugins/events_spec.js',
'lms/include/js/spec/edxnotes/plugins/scroller_spec.js', 'lms/include/js/spec/edxnotes/plugins/scroller_spec.js',
'lms/include/js/spec/edxnotes/collections/notes_spec.js' 'lms/include/js/spec/edxnotes/collections/notes_spec.js',
'lms/include/js/spec/search/search_spec.js'
]); ]);
}).call(this, requirejs, define); }).call(this, requirejs, define);
...@@ -78,6 +78,7 @@ spec_paths: ...@@ -78,6 +78,7 @@ spec_paths:
# loadFixtures('path/to/fixture/fixture.html'); # loadFixtures('path/to/fixture/fixture.html');
# #
fixture_paths: fixture_paths:
- js/fixtures
- templates/instructor/instructor_dashboard_2 - templates/instructor/instructor_dashboard_2
- templates/dashboard - templates/dashboard
- templates/edxnotes - templates/edxnotes
...@@ -86,6 +87,7 @@ fixture_paths: ...@@ -86,6 +87,7 @@ fixture_paths:
- templates/verify_student - templates/verify_student
- templates/file-upload.underscore - templates/file-upload.underscore
- js/fixtures/edxnotes - js/fixtures/edxnotes
- templates/courseware_search
requirejs: requirejs:
paths: paths:
......
...@@ -41,6 +41,11 @@ ...@@ -41,6 +41,11 @@
@import 'course/courseware/sidebar'; @import 'course/courseware/sidebar';
@import 'course/courseware/amplifier'; @import 'course/courseware/amplifier';
## Import styles for courseware search
% if env["FEATURES"].get("ENABLE_COURSEWARE_SEARCH"):
@import 'course/courseware/courseware_search';
% endif
// course - modules // course - modules
@import 'course/modules/student-notes'; // student notes @import 'course/modules/student-notes'; // student notes
@import 'course/modules/calculator'; // calculator utility @import 'course/modules/calculator'; // calculator utility
......
.course-index .courseware-search-bar {
@include box-sizing(border-box);
position: relative;
padding: 5px;
box-shadow: 0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, .1) inset;
font-family: $sans-serif;
.search-field {
@include box-sizing(border-box);
top: 5px;
width: 100%;
@include border-radius(4px);
background: $white-t1;
&.is-active {
background: $white;
}
}
.search-button, .cancel-button {
@include box-sizing(border-box);
color: #888;
font-size: 14px;
font-weight: normal;
display: block;
position: absolute;
right: 12px;
top: 5px;
height: 35px;
line-height: 35px;
padding: 0;
border: none;
box-shadow: none;
background: transparent;
}
.cancel-button {
display: none;
}
}
.courseware-search-results {
display: none;
padding: 40px;
.search-info {
padding-bottom: lh(.75);
border-bottom: 1px solid lighten($border-color, 10%);
.search-count {
float: right;
color: $gray;
}
}
.search-results {
padding: 0;
}
.search-results-item {
position: relative;
border-bottom: 1px solid lighten($border-color, 10%);
list-style-type: none;
margin-bottom: lh(.75);
padding-bottom: lh(.75);
padding-right: 140px;
.sri-excerpt {
color: $gray;
margin-bottom: lh(1);
}
.sri-type {
position: absolute;
right: 0;
top: 0;
color: $gray;
}
.sri-link {
position: absolute;
right: 0;
line-height: 1.6em;
bottom: lh(.75);
text-transform: uppercase;
}
}
.search-load-next {
display: block;
text-transform: uppercase;
color: $base-font-color;
border: 2px solid $link-color;
@include border-radius(3px);
padding: 1rem;
.icon-spin {
display: none;
}
}
}
...@@ -23,8 +23,14 @@ ${page_title_breadcrumbs(course_name())} ...@@ -23,8 +23,14 @@ ${page_title_breadcrumbs(course_name())}
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
% endfor % endfor
</%block>
% for template_name in ["search_item", "search_list", "search_loading", "search_error"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="courseware_search/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="headextra"> <%block name="headextra">
<%static:css group='style-course-vendor'/> <%static:css group='style-course-vendor'/>
...@@ -201,6 +207,16 @@ ${fragment.foot_html()} ...@@ -201,6 +207,16 @@ ${fragment.foot_html()}
<a href="#">${_("close")}</a> <a href="#">${_("close")}</a>
</header> </header>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<div id="courseware-search-bar" class="courseware-search-bar">
<form role="search-form">
<input type="text" class="search-field"/>
<button type="submit" class="search-button" aria-label="${_('Search')}">${_('search')} <i class="icon fa fa-search"></i></button>
<button type="button" class="cancel-button" aria-label="${_('Cancel')}"><i class="icon fa fa-remove"></i></button>
</form>
</div>
% endif
<div id="accordion" style="display: none"> <div id="accordion" style="display: none">
<nav aria-label="${_('Course Navigation')}"> <nav aria-label="${_('Course Navigation')}">
% if accordion.strip(): % if accordion.strip():
...@@ -212,10 +228,13 @@ ${fragment.foot_html()} ...@@ -212,10 +228,13 @@ ${fragment.foot_html()}
</div> </div>
</div> </div>
% endif % endif
<section class="course-content" id="course-content"> <section class="course-content" id="course-content">
${fragment.body_html()} ${fragment.body_html()}
</section> </section>
% if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH'):
<section class="courseware-search-results" id="courseware-search-results" data-course-id="${course.id}" data-course-name="${course.display_name_with_default}">
</section>
% endif
</div> </div>
</div> </div>
......
<%= gettext("There was an error, try searching again.") %>
<div class='sri-excerpt'><%= excerpt %></div>
<span class='sri-type'><%- content_type %></span>
<span class='sri-location'><%- location.join(' ▸ ') %></span>
<a class="sri-link" href="<%- url %>"><%= gettext("View") %> <i class="icon-arrow-right"></i></a>
<div class="search-info">
<%- interpolate(gettext("Searching %s"), [courseName]) %>
<div class="search-count"><%- totalCountMsg %></div>
</div>
<% if (totalCount > 0 ) { %>
<ol class='search-results'></ol>
<% if (hasMoreResults) { %>
<a class="search-load-next" href="javascript:void(0);">
<%- interpolate(gettext("Load next %s results"), [pageSize]) %>
<i class="icon fa-spinner fa-spin"></i>
</a>
<% } %>
<% } else { %>
<p><%- gettext("Sorry, no results were found.") %></p>
<% } %>
<i class="icon fa fa-spinner fa-spin"></i> <%= gettext("Loading") %>
...@@ -10,6 +10,8 @@ from microsite_configuration import microsite ...@@ -10,6 +10,8 @@ from microsite_configuration import microsite
if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'):
admin.autodiscover() admin.autodiscover()
# Use urlpatterns formatted as within the Django docs with first parameter "stuck" to the open parenthesis
# pylint: disable=bad-continuation
urlpatterns = ('', # nopep8 urlpatterns = ('', # nopep8
# certificate view # certificate view
url(r'^update_certificate$', 'certificates.views.update_certificate'), url(r'^update_certificate$', 'certificates.views.update_certificate'),
...@@ -79,6 +81,9 @@ urlpatterns = ('', # nopep8 ...@@ -79,6 +81,9 @@ urlpatterns = ('', # nopep8
# CourseInfo API RESTful endpoints # CourseInfo API RESTful endpoints
url(r'^api/course/details/v0/', include('course_about.urls')), url(r'^api/course/details/v0/', include('course_about.urls')),
# Courseware search endpoints
url(r'^search/', include('search.urls')),
) )
if settings.FEATURES["ENABLE_MOBILE_REST_API"]: if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
......
...@@ -35,6 +35,7 @@ django-threaded-multihost==1.4-1 ...@@ -35,6 +35,7 @@ django-threaded-multihost==1.4-1
django-method-override==0.1.0 django-method-override==0.1.0
djangorestframework==2.3.14 djangorestframework==2.3.14
django==1.4.18 django==1.4.18
elasticsearch==0.4.5
feedparser==5.1.3 feedparser==5.1.3
firebase-token-generator==1.3.2 firebase-token-generator==1.3.2
# Master pyfs has a bug working with VPC auth. This is a fix. We should switch # Master pyfs has a bug working with VPC auth. This is a fix. We should switch
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a0c695#egg=django-cas git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a0c695#egg=django-cas
# Our libraries: # Our libraries:
-e git+https://github.com/edx/XBlock.git@9c634481dfc85a17dcb3351ca232d7098a38e10e#egg=XBlock -e git+https://github.com/edx/XBlock.git@3682847a91acac6640a330fbe797ef56ce988517#egg=XBlock
-e git+https://github.com/edx/codejail.git@2b095e820ff752a108653bb39d518b122f7154db#egg=codejail -e git+https://github.com/edx/codejail.git@2b095e820ff752a108653bb39d518b122f7154db#egg=codejail
-e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool -e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool
-e git+https://github.com/edx/event-tracking.git@0.1.0#egg=event-tracking -e git+https://github.com/edx/event-tracking.git@0.1.0#egg=event-tracking
...@@ -36,3 +36,4 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a ...@@ -36,3 +36,4 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a
-e git+https://github.com/edx/edx-val.git@ba00a5f2e0571e9a3f37d293a98efe4cbca850d5#egg=edx-val -e git+https://github.com/edx/edx-val.git@ba00a5f2e0571e9a3f37d293a98efe4cbca850d5#egg=edx-val
-e git+https://github.com/pmitros/RecommenderXBlock.git@9b07e807c89ba5761827d0387177f71aa57ef056#egg=recommender-xblock -e git+https://github.com/pmitros/RecommenderXBlock.git@9b07e807c89ba5761827d0387177f71aa57ef056#egg=recommender-xblock
-e git+https://github.com/edx/edx-milestones.git@547f2250ee49e73ce8d7ff4e78ecf1b049892510#egg=edx-milestones -e git+https://github.com/edx/edx-milestones.git@547f2250ee49e73ce8d7ff4e78ecf1b049892510#egg=edx-milestones
-e git+https://github.com/edx/edx-search.git@264bb3317f98e9cb22b932aa11b89d0651fd741c#egg=edx-search
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment