Merge pull request #6506 from edx/feature/courseware_search
Courseware search
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
135
common/lib/xmodule/xmodule/modulestore/courseware_index.py
Normal file
135
common/lib/xmodule/xmodule/modulestore/courseware_index.py
Normal file
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
39
common/test/acceptance/pages/lms/courseware_search.py
Normal file
39
common/test/acceptance/pages/lms/courseware_search.py
Normal file
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
194
common/test/acceptance/tests/lms/test_lms_courseware_search.py
Normal file
194
common/test/acceptance/tests/lms/test_lms_courseware_search.py
Normal file
@@ -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]
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
10
lms/lib/courseware_search/__init__.py
Normal file
10
lms/lib/courseware_search/__init__.py
Normal file
@@ -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
|
||||
"""
|
||||
100
lms/lib/courseware_search/lms_result_processor.py
Normal file
100
lms/lib/courseware_search/lms_result_processor.py
Normal file
@@ -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()
|
||||
)
|
||||
138
lms/lib/courseware_search/test/test_lms_result_processor.py
Normal file
138
lms/lib/courseware_search/test/test_lms_result_processor.py
Normal file
@@ -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)
|
||||
7
lms/static/js/fixtures/search_form.html
Normal file
7
lms/static/js/fixtures/search_form.html
Normal file
@@ -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>
|
||||
92
lms/static/js/search/collections/search_collection.js
Normal file
92
lms/static/js/search/collections/search_collection.js
Normal file
@@ -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);
|
||||
12
lms/static/js/search/main.js
Normal file
12
lms/static/js/search/main.js
Normal file
@@ -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();
|
||||
|
||||
});
|
||||
17
lms/static/js/search/models/search_result.js
Normal file
17
lms/static/js/search/models/search_result.js
Normal file
@@ -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);
|
||||
37
lms/static/js/search/search_app.js
Normal file
37
lms/static/js/search/search_app.js
Normal file
@@ -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);
|
||||
14
lms/static/js/search/search_router.js
Normal file
14
lms/static/js/search/search_router.js
Normal file
@@ -0,0 +1,14 @@
|
||||
;(function (define) {
|
||||
|
||||
define(['backbone'], function (Backbone) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Router.extend({
|
||||
routes: {
|
||||
'search/:query': 'search'
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
})(define || RequireJS.define);
|
||||
65
lms/static/js/search/views/search_form.js
Normal file
65
lms/static/js/search/views/search_form.js
Normal file
@@ -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);
|
||||
32
lms/static/js/search/views/search_item_view.js
Normal file
32
lms/static/js/search/views/search_item_view.js
Normal file
@@ -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);
|
||||
97
lms/static/js/search/views/search_list_view.js
Normal file
97
lms/static/js/search/views/search_list_view.js
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
513
lms/static/js/spec/search/search_spec.js
Normal file
513
lms/static/js/spec/search/search_spec.js
Normal file
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
101
lms/static/sass/course/courseware/_courseware_search.scss
Normal file
101
lms/static/sass/course/courseware/_courseware_search.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
1
lms/templates/courseware_search/search_error.underscore
Normal file
1
lms/templates/courseware_search/search_error.underscore
Normal file
@@ -0,0 +1 @@
|
||||
<%= gettext("There was an error, try searching again.") %>
|
||||
4
lms/templates/courseware_search/search_item.underscore
Normal file
4
lms/templates/courseware_search/search_item.underscore
Normal file
@@ -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>
|
||||
21
lms/templates/courseware_search/search_list.underscore
Normal file
21
lms/templates/courseware_search/search_list.underscore
Normal file
@@ -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>
|
||||
|
||||
<% } %>
|
||||
@@ -0,0 +1,2 @@
|
||||
<i class="icon fa fa-spinner fa-spin"></i> <%= gettext("Loading") %>
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user