diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index ecf18c2f64ca659827894476a9988ba2f71d8cb9..7fa8d7a7b6c438b0d593fc3d6e8a60907efb0732 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _ import django.utils from django.contrib.auth.decorators import login_required 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.urlresolvers import reverse from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404 @@ -22,6 +22,7 @@ from edxmako.shortcuts import render_to_response from xmodule.course_module import DEFAULT_START_DATE from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore +from xmodule.modulestore.courseware_index import CoursewareSearchIndexer, SearchIndexingError from xmodule.contentstore.content import StaticContent from xmodule.tabs import PDFTextbookTabs from xmodule.partitions.partitions import UserPartition @@ -75,6 +76,7 @@ from course_action_state.managers import CourseActionStateItemNotFoundError from microsite_configuration import microsite from xmodule.course_module import CourseFields from xmodule.split_test_module import get_split_user_partitions +from student.auth import has_course_author_access from util.milestones_helpers import ( set_prerequisite_courses, @@ -90,7 +92,7 @@ CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration' __all__ = ['course_info_handler', 'course_handler', 'course_listing', - 'course_info_update_handler', + 'course_info_update_handler', 'course_search_index_handler', 'course_rerun_handler', 'settings_handler', 'grading_handler', @@ -121,6 +123,15 @@ def get_course_and_check_access(course_key, user, depth=0): 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 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): }) +@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): """ Returns a JSON representation of the course module and recursively all of its children. diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index 32b5ccbf2cf2bffa9bd13a45e1ada817e9faf137..ed226a926edbc1eebf1bb42d4898c4d89514b103 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -4,21 +4,28 @@ Unit tests for getting the list of courses and the course outline. import json import lxml import datetime +import os +import mock from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url, reverse_library_url, add_instructor from student.auth import has_course_author_access -from contentstore.views.course import course_outline_initial_state +from contentstore.views.course import course_outline_initial_state, reindex_course_and_check_access from contentstore.views.item import create_xblock_info, VisibilityState from course_action_state.models import CourseRerunState from util.date_utils import get_default_time_display from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.courseware_index import CoursewareSearchIndexer +from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory +from xmodule.modulestore.courseware_index import SearchIndexingError from opaque_keys.edx.locator import CourseLocator from student.tests.factories import UserFactory from course_action_state.managers import CourseRerunUIStateManager from django.conf import settings +from django.core.exceptions import PermissionDenied +from search.api import perform_search import pytz @@ -226,6 +233,7 @@ class TestCourseOutline(CourseTestCase): Set up the for the course outline tests. """ super(TestCourseOutline, self).setUp() + self.chapter = ItemFactory.create( parent_location=self.course.location, category='chapter', display_name="Week 1" ) @@ -330,3 +338,304 @@ class TestCourseOutline(CourseTestCase): self.assertEqual(_get_release_date(response), get_default_time_display(self.course.start)) _assert_settings_link_present(response) + + +class TestCourseReIndex(CourseTestCase): + """ + Unit tests for the course outline. + """ + + TEST_INDEX_FILENAME = "test_root/index_file.dat" + + def setUp(self): + """ + Set up the for the course outline tests. + """ + + super(TestCourseReIndex, self).setUp() + + self.course.start = datetime.datetime(2014, 1, 1, tzinfo=pytz.utc) + modulestore().update_item(self.course, self.user.id) + + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name="Week 1" + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name="Lesson 1" + ) + self.vertical = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1' + ) + self.video = ItemFactory.create( + parent_location=self.vertical.location, category="video", display_name="My Video" + ) + + self.html = ItemFactory.create( + parent_location=self.vertical.location, category="html", display_name="My HTML", + data="<div>This is my unique HTML content</div>", + + ) + + # 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) + + def test_reindex_course(self): + """ + Verify that course gets reindexed. + """ + index_url = reverse_course_url('course_search_index_handler', self.course.id) + response = self.client.get(index_url, {}, HTTP_ACCEPT='application/json') + + # A course with the default release date should display as "Unscheduled" + self.assertEqual(response.content, '') + self.assertEqual(response.status_code, 200) + + response = self.client.post(index_url, {}, HTTP_ACCEPT='application/json') + self.assertEqual(response.content, '') + self.assertEqual(response.status_code, 405) + + self.client.logout() + response = self.client.get(index_url, {}, HTTP_ACCEPT='application/json') + self.assertEqual(response.status_code, 302) + + def test_negative_conditions(self): + """ + Test the error conditions for the access + """ + index_url = reverse_course_url('course_search_index_handler', self.course.id) + # register a non-staff member and try to delete the course branch + non_staff_client, _ = self.create_non_staff_authed_user_client() + response = non_staff_client.get(index_url, {}, HTTP_ACCEPT='application/json') + self.assertEqual(response.status_code, 403) + + def test_reindex_json_responses(self): + """ + Test json response with real data + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # Start manual reindex + reindex_course_and_check_access(self.course.id, self.user) + + self.html.display_name = "My expanded HTML" + modulestore().update_item(self.html, ModuleStoreEnum.UserID.test) + + # Start manual reindex + reindex_course_and_check_access(self.course.id, self.user) + + # Check results indexed now + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['total'], 1) + + @mock.patch('xmodule.video_module.VideoDescriptor.index_dictionary') + def test_reindex_video_error_json_responses(self, mock_index_dictionary): + """ + Test json response with mocked error data for video + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # set mocked exception response + err = Exception + mock_index_dictionary.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + reindex_course_and_check_access(self.course.id, self.user) + + @mock.patch('xmodule.html_module.HtmlDescriptor.index_dictionary') + def test_reindex_html_error_json_responses(self, mock_index_dictionary): + """ + Test json response with rmocked error data for html + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # set mocked exception response + err = Exception + mock_index_dictionary.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + reindex_course_and_check_access(self.course.id, self.user) + + @mock.patch('xmodule.seq_module.SequenceDescriptor.index_dictionary') + def test_reindex_seq_error_json_responses(self, mock_index_dictionary): + """ + Test json response with rmocked error data for sequence + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # set mocked exception response + err = Exception + mock_index_dictionary.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + reindex_course_and_check_access(self.course.id, self.user) + + @mock.patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_course') + def test_reindex_no_item(self, mock_get_course): + """ + Test system logs an error if no item found. + """ + # set mocked exception response + err = ItemNotFoundError + mock_get_course.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + reindex_course_and_check_access(self.course.id, self.user) + + def test_reindex_no_permissions(self): + # register a non-staff member and try to delete the course branch + user2 = UserFactory() + with self.assertRaises(PermissionDenied): + reindex_course_and_check_access(self.course.id, user2) + + def test_indexing_responses(self): + """ + Test add_to_search_index response with real data + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # Start manual reindex + errors = CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id) + self.assertEqual(errors, None) + + self.html.display_name = "My expanded HTML" + modulestore().update_item(self.html, ModuleStoreEnum.UserID.test) + + # Start manual reindex + errors = CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id) + self.assertEqual(errors, None) + + # Check results indexed now + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['total'], 1) + + @mock.patch('xmodule.video_module.VideoDescriptor.index_dictionary') + def test_indexing_video_error_responses(self, mock_index_dictionary): + """ + Test add_to_search_index response with mocked error data for video + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # set mocked exception response + err = Exception + mock_index_dictionary.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id) + + @mock.patch('xmodule.html_module.HtmlDescriptor.index_dictionary') + def test_indexing_html_error_responses(self, mock_index_dictionary): + """ + Test add_to_search_index response with mocked error data for html + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # set mocked exception response + err = Exception + mock_index_dictionary.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id) + + @mock.patch('xmodule.seq_module.SequenceDescriptor.index_dictionary') + def test_indexing_seq_error_responses(self, mock_index_dictionary): + """ + Test add_to_search_index response with mocked error data for sequence + """ + # Check results not indexed + response = perform_search( + "unique", + user=self.user, + size=10, + from_=0, + course_id=unicode(self.course.id)) + self.assertEqual(response['results'], []) + + # set mocked exception response + err = Exception + mock_index_dictionary.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id) + + @mock.patch('xmodule.modulestore.mongo.base.MongoModuleStore.get_course') + def test_indexing_no_item(self, mock_get_course): + """ + Test system logs an error if no item found. + """ + # set mocked exception response + err = ItemNotFoundError + mock_get_course.return_value = err + + # Start manual reindex and check error in response + with self.assertRaises(SearchIndexingError): + CoursewareSearchIndexer.do_course_reindex(modulestore(), self.course.id) + + def tearDown(self): + os.remove(self.TEST_INDEX_FILENAME) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index cd8afef3329819e12d43471b1a87557154ed8c3f..3132b03b377077e8fa5759ddd97aa7726989f485 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -316,3 +316,7 @@ API_DATE_FORMAT = ENV_TOKENS.get('API_DATE_FORMAT', API_DATE_FORMAT) # Video Caching. Pairing country codes with CDN URLs. # Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_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" diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py index 6c645e54543d5ebc48e0720a19853dbee2f841fc..0eaf66155fe5ae92429cbcdd40f63ebaeb913190 100644 --- a/cms/envs/bok_choy.py +++ b/cms/envs/bok_choy.py @@ -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['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. try: diff --git a/cms/envs/common.py b/cms/envs/common.py index 7335f43a4fbcb3d50cec150e436c489ea170ce65..071091b94817e994589767ab56f47cb157f3e0a2 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -146,6 +146,9 @@ FEATURES = { # Toggle course entrance exams feature 'ENTRANCE_EXAMS': False, + + # Enable the courseware search functionality + 'ENABLE_COURSEWARE_INDEX': False, } ENABLE_JASMINE = False @@ -868,3 +871,11 @@ FILES_AND_UPLOAD_TYPE_FILTERS = { 'application/vnd.ms-powerpoint', ], } + +# Default to no Search Engine +SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" +ELASTIC_FIELD_MAPPINGS = { + "start_date": { + "type": "date" + } +} diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index f714172955441595e718ca30df3f8a5822e2487f..1b8f94c92ae1e9baa5986859157bfe2db76de5d2 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -83,6 +83,9 @@ FEATURES['MILESTONES_APP'] = True ################################ ENTRANCE EXAMS ################################ FEATURES['ENTRANCE_EXAMS'] = True +################################ SEARCH INDEX ################################ +FEATURES['ENABLE_COURSEWARE_INDEX'] = True +SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" ############################################################################### # See if the developer has any local overrides. diff --git a/cms/envs/test.py b/cms/envs/test.py index 7b4be67f2f081cc3060077e9ac41a1c280323ae7..ecc9369277b5913255cc6ff939b0deecfbe612c8 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -254,3 +254,11 @@ ENTRANCE_EXAM_MIN_SCORE_PCT = 50 VIDEO_CDN_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() diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index 77462d11379e90593bcf7ae74d89fe66476f5bbe..bfc0285fe643508476a03189048f4a08b0d4ad73 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -8,7 +8,7 @@ define(["jquery", "js/common_helpers/ajax_helpers", "js/views/utils/view_utils", getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState, createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON, verifyTypePublishable, mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, - createMockVerticalJSON, + createMockVerticalJSON, createMockIndexJSON, mockOutlinePage = readFixtures('mock/mock-course-outline-page.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", }, 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) { return outlinePage.$('.outline-' + type); }; @@ -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(); 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() { diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index 4e815fd08bd375403ff98ced83f8101be2508416..806e135e2de130a833fd6cb31ccf3b8001609fca 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -2,8 +2,8 @@ * 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", - "js/views/course_outline", "js/views/utils/view_utils"], - function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils) { + "js/views/course_outline", "js/views/utils/view_utils", "js/views/feedback_alert"], + function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, AlertView) { var expandedLocators, CourseOutlinePage; CourseOutlinePage = BasePage.extend({ @@ -24,6 +24,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views this.$('.button-new').click(function(event) { self.outlineView.handleAddEvent(event); }); + this.$('.button.button-reindex').click(function(event) { + self.handleReIndexEvent(event); + }); this.model.on('change', this.setCollapseExpandVisibility, this); $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden'); @@ -100,6 +103,32 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views } }, 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(); } }); diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 032c4e7891641871ec69aaccc1a033b4b2b6e63c..aac283fa0fa2381d0867fc3fdc7696da39c812b8 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -68,6 +68,11 @@ from contentstore.utils import reverse_usage_url <i class="icon fa fa-plus"></i>${_('New Section')} </a> </li> + <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"> <span class="collapse-all"><i class="icon fa fa-arrow-up"></i> <span class="label">${_("Collapse All Sections")}</span></span> diff --git a/cms/templates/js/mock/mock-course-outline-page.underscore b/cms/templates/js/mock/mock-course-outline-page.underscore index ed1f8b81284c9b4800478bdbf8e6054230eb4636..2b543db7c497854edf1b119c948f5fec096c9579 100644 --- a/cms/templates/js/mock/mock-course-outline-page.underscore +++ b/cms/templates/js/mock/mock-course-outline-page.underscore @@ -21,6 +21,11 @@ <i class="icon fa fa-plus"></i>New Section </a> </li> + <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> </li> diff --git a/cms/urls.py b/cms/urls.py index 4a216e1883c17b363c5f23dec8da2415571dcd00..4ca692f109758b290bf83cdd65fad7d67e29cd71 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<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'), diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index acea5a3e55e5f00f687a297b50bede9dd645d662..13ca86894b48731095b2c5b3fecb0957746769ee 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"<!\[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): 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 0000000000000000000000000000000000000000..1aebdd3babcb4f074c9408bddd767b9585c0aa43 --- /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 eaf808d18d8478e27b7f6ba371a4e030f6ea8961..a0d80013591f261a6a7f63c097791e52b8d17641 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 d603287b082324ae1fe13d7fdce788a7b42ec080..fc1c174b8fe2cac9d7def7323d9db62781953f84 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 7ebb9d94e535e5821dd914e29c1400c62c8943c8..1340f06c2b501bcb1ae81f2783292954a6179b26 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 2156f0d18f70ab4f87b5627e61f721cbc155f65e..eab9168c1a33f5f39ab2eb891ee1b2741b32e5d0 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 da1e0d49a63f9caa4a450b0732de5f727a7292b2..e55882ebe5395d91a37c7fae818e603f6eabd73d 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 = ''' + <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" + }) diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py index f92c3f9cb1fa058d08ddf5ffda84530191db60f5..de9de5dea651feaca62a8b8cb515277f064e0cfd 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 = '''\ <video url_name="SampleProblem" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00" download_video="true" download_track="true"> <source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.ogg"/> @@ -514,7 +573,8 @@ class VideoExportTestCase(VideoDescriptorTestBase): <transcript language="ge" src="german_translation.srt" /> <transcript language="ua" src="ukrainian_translation.srt" /> </video> - ''') + ''' + 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 = '''\ <video url_name="SampleProblem" start_time="0:00:05" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" download_video="true" download_track="true"> <source src="http://www.example.com/source.mp4"/> <source src="http://www.example.com/source.ogg"/> <track src="http://www.example.com/track"/> </video> - ''') - + ''' + 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 = ''' + <video display_name="Test Video" + youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8" + show_captions="false" + download_track="false" + start_time="00:00:01" + download_video="false" + end_time="00:01:00"> + <source src="http://www.example.com/source.mp4"/> + <track src="http://www.example.com/track"/> + <handout src="http://www.example.com/handout"/> + </video> + ''' + descriptor = instantiate_descriptor(data=xml_data) + self.assertEqual(descriptor.index_dictionary(), { + "content": {"display_name": "Test Video"}, + "content_type": "Video" + }) + + xml_data_sub = ''' + <video display_name="Test Video" + youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8" + show_captions="false" + download_track="false" + sub="OEoXaMPEzfM" + start_time="00:00:01" + download_video="false" + end_time="00:01:00"> + <source src="http://www.example.com/source.mp4"/> + <track src="http://www.example.com/track"/> + <handout src="http://www.example.com/handout"/> + </video> + ''' + + 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 = ''' + <video display_name="Test Video" + youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8" + show_captions="false" + download_track="false" + sub="OEoXaMPEzfM" + start_time="00:00:01" + download_video="false" + end_time="00:01:00"> + <source src="http://www.example.com/source.mp4"/> + <track src="http://www.example.com/track"/> + <handout src="http://www.example.com/handout"/> + <transcript language="ge" src="subs_grmtran1.srt" /> + </video> + ''' + + 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 = ''' + <video display_name="Test Video" + youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8" + show_captions="false" + download_track="false" + start_time="00:00:01" + download_video="false" + end_time="00:01:00"> + <source src="http://www.example.com/source.mp4"/> + <track src="http://www.example.com/track"/> + <handout src="http://www.example.com/handout"/> + <transcript language="ge" src="subs_grmtran1.srt" /> + <transcript language="hr" src="subs_croatian1.srt" /> + </video> + ''' + + 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 2a374ab2f9e1b4d9088c484b2b00b6654660f977..56467e91a4e61c33f4cda0fea83aafba648719c0 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 0000000000000000000000000000000000000000..58cfec3b49ae061eb7114e9a41fe4b1dbae93e1e --- /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 6db9c9cc2be44a38f2ba8b19c9ef19786a3f89f6..1dd1cb0f504d910ab484fcb47a4bbaa76e97d04c 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 df86d5decae481956875761ba1d1db1739507b53..1c611e7ba3d96e7b5c70bbce21eda28d2fede1fa 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 0000000000000000000000000000000000000000..d6a91a7ba6d986f62037351bdfc5aa109764d278 --- /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 60509a12bfd7250a1d354b6a48e30bf65c28ed8f..c87aee700a7ee74ca263890397723a5faff8d102 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 e0f117246db42de6f6f2f7946c8302311b37e208..72c26b34fbe52d89868c67b10e834a85c1518216 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 325812a5464f922b87cd640bd4062c0a84ab5efd..9517e63e5e3512b2d5c36c9e8003437f6dd2cfc7 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 c409c3138a99d439f2eec4a9252e6b6ebf2e3ffc..28050f4876e5703a7d1c55ac25f3f372a47d2f85 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 f9d4012cebfd63018d10de1367cb41e88843dabc..4606d6f3023f321e565f1f8ab377e7ee0895be91 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 c26cfdeec6b6f81d89f34d9ee188b5a6722cfed6..43f52dc4ea13863f1b68d3b31148aecaad12af12 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 0000000000000000000000000000000000000000..8e21f753229b99d9bd35f9629a6fc3ce6014bf2d --- /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 0000000000000000000000000000000000000000..d9a2cec97b421c755d1f35290ebb323586037980 --- /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 0000000000000000000000000000000000000000..0f2a77e7e0ca1fa66fdc405ec5590e1f018dfb3f --- /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 0000000000000000000000000000000000000000..77c9a1dad84f6cd77bdc7d8d95ce45d3061a6040 --- /dev/null +++ b/lms/static/js/fixtures/search_form.html @@ -0,0 +1,7 @@ +<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> diff --git a/lms/static/js/search/collections/search_collection.js b/lms/static/js/search/collections/search_collection.js new file mode 100644 index 0000000000000000000000000000000000000000..bd5689f5129aa4d57f080735816d83e3fc143c18 --- /dev/null +++ b/lms/static/js/search/collections/search_collection.js @@ -0,0 +1,92 @@ +;(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); diff --git a/lms/static/js/search/main.js b/lms/static/js/search/main.js new file mode 100644 index 0000000000000000000000000000000000000000..f97d3eb95c92010bb5918ea3f624026788237598 --- /dev/null +++ b/lms/static/js/search/main.js @@ -0,0 +1,12 @@ +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(); + +}); diff --git a/lms/static/js/search/models/search_result.js b/lms/static/js/search/models/search_result.js new file mode 100644 index 0000000000000000000000000000000000000000..63821f71599241a46ebe98d7c46ffe7e92dcca22 --- /dev/null +++ b/lms/static/js/search/models/search_result.js @@ -0,0 +1,17 @@ +;(function (define) { + +define(['backbone'], function (Backbone) { + 'use strict'; + + return Backbone.Model.extend({ + defaults: { + location: [], + content_type: '', + excerpt: '', + url: '' + } + }); + +}); + +})(define || RequireJS.define); diff --git a/lms/static/js/search/search_app.js b/lms/static/js/search/search_app.js new file mode 100644 index 0000000000000000000000000000000000000000..bf69ac393062c2e9d5fd0515a138e236f6a437cf --- /dev/null +++ b/lms/static/js/search/search_app.js @@ -0,0 +1,37 @@ +;(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); diff --git a/lms/static/js/search/search_router.js b/lms/static/js/search/search_router.js new file mode 100644 index 0000000000000000000000000000000000000000..65683b3f1e80f3bdbbd34872adba99bb61f344ca --- /dev/null +++ b/lms/static/js/search/search_router.js @@ -0,0 +1,14 @@ +;(function (define) { + +define(['backbone'], function (Backbone) { + 'use strict'; + + return Backbone.Router.extend({ + routes: { + 'search/:query': 'search' + } + }); + +}); + +})(define || RequireJS.define); diff --git a/lms/static/js/search/views/search_form.js b/lms/static/js/search/views/search_form.js new file mode 100644 index 0000000000000000000000000000000000000000..3eb407ac6aef9c8ff538b0bbf102593b6b1b8499 --- /dev/null +++ b/lms/static/js/search/views/search_form.js @@ -0,0 +1,65 @@ +;(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); diff --git a/lms/static/js/search/views/search_item_view.js b/lms/static/js/search/views/search_item_view.js new file mode 100644 index 0000000000000000000000000000000000000000..439c4fd3577eba39d4345427cee07fbb1d70908f --- /dev/null +++ b/lms/static/js/search/views/search_item_view.js @@ -0,0 +1,32 @@ +;(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); diff --git a/lms/static/js/search/views/search_list_view.js b/lms/static/js/search/views/search_list_view.js new file mode 100644 index 0000000000000000000000000000000000000000..b981264b4ce404323631c713d7909bcd128da973 --- /dev/null +++ b/lms/static/js/search/views/search_list_view.js @@ -0,0 +1,97 @@ +;(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); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index d408a7988ab685802afabee0da441c4fdcefb40f..76036ab9b07175cabadf3bc0c2734fd0b386e5e1 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -560,7 +560,8 @@ 'lms/include/js/spec/edxnotes/models/note_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/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); diff --git a/lms/static/js/spec/search/search_spec.js b/lms/static/js/spec/search/search_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cf0aa5fa3a283aa4e81a5b9dec5fbe8ae0161fa7 --- /dev/null +++ b/lms/static/js/spec/search/search_spec.js @@ -0,0 +1,513 @@ +define([ + 'jquery', + 'sinon', + 'backbone', + 'js/common_helpers/template_helpers', + 'js/search/views/search_form', + 'js/search/views/search_item_view', + 'js/search/views/search_list_view', + 'js/search/models/search_result', + 'js/search/collections/search_collection', + 'js/search/search_router', + 'js/search/search_app' +], function( + $, + Sinon, + Backbone, + TemplateHelpers, + SearchForm, + SearchItemView, + SearchListView, + SearchResult, + SearchCollection, + SearchRouter, + SearchApp +) { + 'use strict'; + + describe('SearchForm', function () { + + beforeEach(function () { + loadFixtures('js/fixtures/search_form.html'); + this.form = new SearchForm(); + this.onClear = jasmine.createSpy('onClear'); + this.onSearch = jasmine.createSpy('onSearch'); + this.form.on('clear', this.onClear); + this.form.on('search', this.onSearch); + }); + + it('trims input string', function () { + var term = ' search string '; + $('.search-field').val(term); + $('form').trigger('submit'); + expect(this.onSearch).toHaveBeenCalledWith($.trim(term)); + }); + + it('handles calls to doSearch', function () { + var term = ' search string '; + $('.search-field').val(term); + this.form.doSearch(term); + expect(this.onSearch).toHaveBeenCalledWith($.trim(term)); + expect($('.search-field').val()).toEqual(term); + expect($('.search-field')).toHaveClass('is-active'); + expect($('.search-button')).toBeHidden(); + expect($('.cancel-button')).toBeVisible(); + }); + + it('triggers a search event and changes to active state', function () { + var term = 'search string'; + $('.search-field').val(term); + $('form').trigger('submit'); + expect(this.onSearch).toHaveBeenCalledWith(term); + expect($('.search-field')).toHaveClass('is-active'); + expect($('.search-button')).toBeHidden(); + expect($('.cancel-button')).toBeVisible(); + }); + + it('clears search when clicking on cancel button', function () { + $('.search-field').val('search string'); + $('.cancel-button').trigger('click'); + expect($('.search-field')).not.toHaveClass('is-active'); + expect($('.search-button')).toBeVisible(); + expect($('.cancel-button')).toBeHidden(); + expect($('.search-field')).toHaveValue(''); + }); + + it('clears search when search box is empty', function() { + $('.search-field').val(''); + $('form').trigger('submit'); + expect(this.onClear).toHaveBeenCalled(); + expect($('.search-field')).not.toHaveClass('is-active'); + expect($('.cancel-button')).toBeHidden(); + expect($('.search-button')).toBeVisible(); + }); + + }); + + + describe('SearchItemView', function () { + + beforeEach(function () { + TemplateHelpers.installTemplate('templates/courseware_search/search_item'); + this.model = { + attributes: { + location: ['section', 'subsection', 'unit'], + content_type: 'Video', + excerpt: 'A short excerpt.', + url: 'path/to/content' + } + }; + this.item = new SearchItemView({ model: this.model }); + }); + + it('has useful html attributes', function () { + expect(this.item.$el).toHaveAttr('role', 'region'); + expect(this.item.$el).toHaveAttr('aria-label', 'search result'); + }); + + it('renders correctly', function () { + var href = this.model.attributes.url; + var breadcrumbs = 'section â–¸ subsection â–¸ unit'; + + this.item.render(); + expect(this.item.$el).toContainHtml(this.model.attributes.content_type); + expect(this.item.$el).toContainHtml(this.model.attributes.excerpt); + expect(this.item.$el).toContain('a[href="'+href+'"]'); + expect(this.item.$el).toContainHtml(breadcrumbs); + }); + + }); + + + describe('SearchResult', function () { + + beforeEach(function () { + this.result = new SearchResult(); + }); + + it('has properties', function () { + expect(this.result.get('location')).toBeDefined(); + expect(this.result.get('content_type')).toBeDefined(); + expect(this.result.get('excerpt')).toBeDefined(); + expect(this.result.get('url')).toBeDefined(); + }); + + }); + + + describe('SearchCollection', function () { + + beforeEach(function () { + this.server = Sinon.fakeServer.create(); + this.collection = new SearchCollection(); + + this.onSearch = jasmine.createSpy('onSearch'); + this.collection.on('search', this.onSearch); + + this.onNext = jasmine.createSpy('onNext'); + this.collection.on('next', this.onNext); + + this.onError = jasmine.createSpy('onError'); + this.collection.on('error', this.onError); + }); + + afterEach(function () { + this.server.restore(); + }); + + it('appends course_id to url', function () { + var collection = new SearchCollection([], { course_id: 'edx101' }); + expect(collection.url).toEqual('/search/edx101'); + }); + + it('sends a request and parses the json result', function () { + this.collection.performSearch('search string'); + var response = { + total: 2, + access_denied_count: 1, + results: [{ + data: { + location: ['section', 'subsection', 'unit'], + url: '/some/url/to/content', + content_type: 'text', + excerpt: 'this is a short excerpt' + } + }] + }; + this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + this.server.respond(); + + expect(this.onSearch).toHaveBeenCalled(); + expect(this.collection.totalCount).toEqual(1); + expect(this.collection.accessDeniedCount).toEqual(1); + expect(this.collection.page).toEqual(0); + expect(this.collection.first().attributes).toEqual(response.results[0].data); + }); + + it('handles errors', function () { + this.collection.performSearch('search string'); + this.server.respond(); + expect(this.onSearch).not.toHaveBeenCalled(); + expect(this.onError).toHaveBeenCalled(); + }); + + it('loads next page', function () { + var response = { total: 35, results: [] }; + this.collection.loadNextPage(); + this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + expect(this.onNext).toHaveBeenCalled(); + expect(this.onError).not.toHaveBeenCalled(); + }); + + it('sends correct paging parameters', function () { + this.collection.performSearch('search string'); + var response = { total: 52, results: [] }; + this.server.respondWith('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + this.server.respond(); + this.collection.loadNextPage(); + this.server.respond(); + spyOn($, 'ajax'); + this.collection.loadNextPage(); + expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url); + expect($.ajax.mostRecentCall.args[0].data).toEqual({ + search_string : 'search string', + page_size : this.collection.pageSize, + page_index : 2 + }); + }); + + it('has next page', function () { + var response = { total: 35, access_denied_count: 5, results: [] }; + this.collection.performSearch('search string'); + this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + expect(this.collection.hasNextPage()).toEqual(true); + this.collection.loadNextPage(); + this.server.respond(); + expect(this.collection.hasNextPage()).toEqual(false); + }); + + it('aborts any previous request', function () { + var response = { total: 35, results: [] }; + + this.collection.performSearch('old search'); + this.collection.performSearch('new search'); + this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + expect(this.onSearch.calls.length).toEqual(1); + + this.collection.performSearch('old search'); + this.collection.cancelSearch(); + this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + expect(this.onSearch.calls.length).toEqual(1); + + this.collection.loadNextPage(); + this.collection.loadNextPage(); + this.server.respond('POST', this.collection.url, [200, {}, JSON.stringify(response)]); + expect(this.onNext.calls.length).toEqual(1); + }); + + describe('reset state', function () { + + beforeEach(function () { + this.collection.page = 2; + this.collection.totalCount = 35; + }); + + it('resets state when performing new search', function () { + this.collection.performSearch('search string'); + expect(this.collection.page).toEqual(0); + expect(this.collection.totalCount).toEqual(0); + }); + + it('resets state when canceling a search', function () { + this.collection.cancelSearch(); + expect(this.collection.page).toEqual(0); + expect(this.collection.totalCount).toEqual(0); + }); + + }); + + }); + + + describe('SearchListView', function () { + + beforeEach(function () { + setFixtures( + '<section id="courseware-search-results" data-course-name="Test Course"></section>' + + '<section id="course-content"></section>' + ); + + TemplateHelpers.installTemplates([ + 'templates/courseware_search/search_item', + 'templates/courseware_search/search_list', + 'templates/courseware_search/search_loading', + 'templates/courseware_search/search_error' + ]); + + var MockCollection = Backbone.Collection.extend({ + hasNextPage: function (){} + }); + this.collection = new MockCollection(); + + // spy on these methods before they are bound to events + spyOn(SearchListView.prototype, 'render').andCallThrough(); + spyOn(SearchListView.prototype, 'renderNext').andCallThrough(); + spyOn(SearchListView.prototype, 'showErrorMessage').andCallThrough(); + + this.listView = new SearchListView({ collection: this.collection }); + }); + + it('shows loading message', function () { + this.listView.showLoadingMessage(); + expect($('#course-content')).toBeHidden(); + expect(this.listView.$el).toBeVisible(); + expect(this.listView.$el).not.toBeEmpty(); + }); + + it('shows error message', function () { + this.listView.showErrorMessage(); + expect($('#course-content')).toBeHidden(); + expect(this.listView.$el).toBeVisible(); + expect(this.listView.$el).not.toBeEmpty(); + }); + + it('returns to content', function () { + this.listView.clear(); + expect($('#course-content')).toBeVisible(); + expect(this.listView.$el).toBeHidden(); + expect(this.listView.$el).toBeEmpty(); + }); + + it('handles events', function () { + this.collection.trigger('search'); + this.collection.trigger('next'); + this.collection.trigger('error'); + + expect(this.listView.render).toHaveBeenCalled(); + expect(this.listView.renderNext).toHaveBeenCalled(); + expect(this.listView.showErrorMessage).toHaveBeenCalled(); + }); + + it('renders a message when there are no results', function () { + this.collection.reset(); + this.listView.render(); + expect(this.listView.$el).toContainHtml('no results'); + expect(this.listView.$el.find('ol')).not.toExist(); + }); + + it('renders search results', function () { + var searchResults = [{ + location: ['section', 'subsection', 'unit'], + url: '/some/url/to/content', + content_type: 'text', + excerpt: 'this is a short excerpt' + }]; + this.collection.set(searchResults); + this.collection.totalCount = 1; + + this.listView.render(); + expect(this.listView.$el.find('ol')[0]).toExist(); + expect(this.listView.$el.find('li').length).toEqual(1); + expect(this.listView.$el).toContainHtml('Test Course'); + expect(this.listView.$el).toContainHtml('this is a short excerpt'); + + this.collection.set(searchResults); + this.collection.totalCount = 2; + this.listView.renderNext(); + expect(this.listView.$el.find('.search-count')).toContainHtml('2'); + expect(this.listView.$el.find('li').length).toEqual(2); + }); + + it('shows a link to load more results', function () { + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return true; }; + this.listView.render(); + expect(this.listView.$el.find('a.search-load-next')[0]).toExist(); + + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return false; }; + this.listView.render(); + expect(this.listView.$el.find('a.search-load-next')[0]).not.toExist(); + }); + + it('triggers an event for next page', function () { + var onNext = jasmine.createSpy('onNext'); + this.listView.on('next', onNext); + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return true; }; + this.listView.render(); + this.listView.$el.find('a.search-load-next').click(); + expect(onNext).toHaveBeenCalled(); + }); + + it('shows a spinner when loading more results', function () { + this.collection.totalCount = 123; + this.collection.hasNextPage = function () { return true; }; + this.listView.render(); + this.listView.loadNext(); + expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeVisible(); + this.listView.renderNext(); + expect(this.listView.$el.find('a.search-load-next .icon')[0]).toBeHidden(); + }); + + }); + + + describe('SearchRouter', function () { + + beforeEach(function () { + this.router = new SearchRouter(); + }); + + it ('has a search route', function () { + expect(this.router.routes['search/:query']).toEqual('search'); + }); + + }); + + + describe('SearchApp', function () { + + beforeEach(function () { + loadFixtures('js/fixtures/search_form.html'); + appendSetFixtures( + '<section id="courseware-search-results" data-course-name="Test Course"></section>' + + '<section id="course-content"></section>' + ); + TemplateHelpers.installTemplates([ + 'templates/courseware_search/search_item', + 'templates/courseware_search/search_list', + 'templates/courseware_search/search_loading', + 'templates/courseware_search/search_error' + ]); + + this.server = Sinon.fakeServer.create(); + this.server.respondWith([200, {}, JSON.stringify({ + total: 1337, + access_denied_count: 12, + results: [{ + data: { + location: ['section', 'subsection', 'unit'], + url: '/some/url/to/content', + content_type: 'text', + excerpt: 'this is a short excerpt' + } + }] + })]); + + Backbone.history.stop(); + this.app = new SearchApp('a/b/c'); + + // start history after the application has finished creating + // all of its routers + Backbone.history.start(); + }); + + afterEach(function () { + this.server.restore(); + }); + + it ('shows loading message on search', function () { + $('.search-field').val('search string'); + $('.search-button').trigger('click'); + expect($('#course-content')).toBeHidden(); + expect($('#courseware-search-results')).toBeVisible(); + expect($('#courseware-search-results')).not.toBeEmpty(); + }); + + it ('performs search', function () { + $('.search-field').val('search string'); + $('.search-button').trigger('click'); + this.server.respond(); + expect($('.search-info')).toExist(); + expect($('.search-results')).toBeVisible(); + }); + + it ('updates navigation history on search', function () { + $('.search-field').val('edx'); + $('.search-button').trigger('click'); + expect(Backbone.history.fragment).toEqual('search/edx'); + }); + + it ('aborts sent search request', function () { + // send search request to server + $('.search-field').val('search string'); + $('.search-button').trigger('click'); + // cancel search + $('.cancel-button').trigger('click'); + this.server.respond(); + // there should be no results + expect($('#course-content')).toBeVisible(); + expect($('#courseware-search-results')).toBeHidden(); + }); + + it ('clears results', function () { + $('.cancel-button').trigger('click'); + expect($('#course-content')).toBeVisible(); + expect($('#courseware-search-results')).toBeHidden(); + }); + + it ('updates navigation history on clear', function () { + $('.cancel-button').trigger('click'); + expect(Backbone.history.fragment).toEqual(''); + }); + + it ('loads next page', function () { + $('.search-field').val('query'); + $('.search-button').trigger('click'); + this.server.respond(); + expect($('.search-load-next')).toBeVisible(); + $('.search-load-next').trigger('click'); + var body = this.server.requests[1].requestBody; + expect(body).toContain('search_string=query'); + expect(body).toContain('page_index=1'); + }); + + it ('navigates to search', function () { + Backbone.history.loadUrl('search/query'); + expect(this.server.requests[0].requestBody).toContain('search_string=query'); + }); + + }); + +}); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 5dfa944db2c0ed7da0b0a0ba025a4a6ff7c8480a..2106ec155e589656a8f3b07f9de6108bfef7d5e2 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -78,6 +78,7 @@ spec_paths: # loadFixtures('path/to/fixture/fixture.html'); # fixture_paths: + - js/fixtures - templates/instructor/instructor_dashboard_2 - templates/dashboard - templates/edxnotes @@ -86,6 +87,7 @@ fixture_paths: - templates/verify_student - templates/file-upload.underscore - js/fixtures/edxnotes + - templates/courseware_search requirejs: paths: diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 7fb09e267712eb1e49f1e7a316ec6061fe56e925..b6cb030d79ca0948e2eaff0e3e1a81ead03aca21 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -41,6 +41,11 @@ @import 'course/courseware/sidebar'; @import 'course/courseware/amplifier'; +## Import styles for courseware search +% if env["FEATURES"].get("ENABLE_COURSEWARE_SEARCH"): + @import 'course/courseware/courseware_search'; +% endif + // course - modules @import 'course/modules/student-notes'; // student notes @import 'course/modules/calculator'; // calculator utility diff --git a/lms/static/sass/course/courseware/_courseware_search.scss b/lms/static/sass/course/courseware/_courseware_search.scss new file mode 100644 index 0000000000000000000000000000000000000000..3d91102e3581ae93210ca3262e9a8e053db5f7f2 --- /dev/null +++ b/lms/static/sass/course/courseware/_courseware_search.scss @@ -0,0 +1,101 @@ +.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; + } + } + +} diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index bcc4e89d7441a9adf5b6b01434fb36a2bdb4c33e..e62c5313797e905a9a04266ac49c1bf0f1b873fd 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -23,8 +23,14 @@ ${page_title_breadcrumbs(course_name())} <%static:include path="js/${template_name}.underscore" /> </script> % 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"> <%static:css group='style-course-vendor'/> @@ -201,6 +207,16 @@ ${fragment.foot_html()} <a href="#">${_("close")}</a> </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"> <nav aria-label="${_('Course Navigation')}"> % if accordion.strip(): @@ -212,10 +228,13 @@ ${fragment.foot_html()} </div> </div> % endif - <section class="course-content" id="course-content"> ${fragment.body_html()} </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> diff --git a/lms/templates/courseware_search/search_error.underscore b/lms/templates/courseware_search/search_error.underscore new file mode 100644 index 0000000000000000000000000000000000000000..12b2f36125e376ddbebd568d24f3c4aee6e02925 --- /dev/null +++ b/lms/templates/courseware_search/search_error.underscore @@ -0,0 +1 @@ +<%= gettext("There was an error, try searching again.") %> diff --git a/lms/templates/courseware_search/search_item.underscore b/lms/templates/courseware_search/search_item.underscore new file mode 100644 index 0000000000000000000000000000000000000000..4bf7724ce0dc8d1e46d589c0a92448eafa593338 --- /dev/null +++ b/lms/templates/courseware_search/search_item.underscore @@ -0,0 +1,4 @@ +<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> diff --git a/lms/templates/courseware_search/search_list.underscore b/lms/templates/courseware_search/search_list.underscore new file mode 100644 index 0000000000000000000000000000000000000000..fdc2d843fc6b43903a167c30b009a6e3b3f5bc8e --- /dev/null +++ b/lms/templates/courseware_search/search_list.underscore @@ -0,0 +1,21 @@ +<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> + +<% } %> diff --git a/lms/templates/courseware_search/search_loading.underscore b/lms/templates/courseware_search/search_loading.underscore new file mode 100644 index 0000000000000000000000000000000000000000..d301f39a3871950308382879725288d1a5a55888 --- /dev/null +++ b/lms/templates/courseware_search/search_loading.underscore @@ -0,0 +1,2 @@ +<i class="icon fa fa-spinner fa-spin"></i> <%= gettext("Loading") %> + diff --git a/lms/urls.py b/lms/urls.py index 3bf18ff8d37ac335d11d3e0e35d14dc657207467..3e752032f349be058dbfeaa58df0046845eea83d 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -10,6 +10,8 @@ from microsite_configuration import microsite if settings.DEBUG or settings.FEATURES.get('ENABLE_DJANGO_ADMIN_SITE'): admin.autodiscover() +# Use urlpatterns formatted as within the Django docs with first parameter "stuck" to the open parenthesis +# pylint: disable=bad-continuation urlpatterns = ('', # nopep8 # certificate view url(r'^update_certificate$', 'certificates.views.update_certificate'), @@ -79,6 +81,9 @@ urlpatterns = ('', # nopep8 # CourseInfo API RESTful endpoints 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"]: diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index fa2059243fc9642a0f551f5b78c83ac39adc5261..96ecd2ae8774780ac0b76099445eb09d37b51a51 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -35,6 +35,7 @@ django-threaded-multihost==1.4-1 django-method-override==0.1.0 djangorestframework==2.3.14 django==1.4.18 +elasticsearch==0.4.5 feedparser==5.1.3 firebase-token-generator==1.3.2 # Master pyfs has a bug working with VPC auth. This is a fix. We should switch diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 059c185e2a64eff091b42ff36d73317d4d9e88c7..9e383373ebe9ddb8233479d63ddf9ad7a0313508 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -21,7 +21,7 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a0c695#egg=django-cas # 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/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 @@ -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/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-search.git@264bb3317f98e9cb22b932aa11b89d0651fd741c#egg=edx-search