diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 772dfd277896..121da9eaead6 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -76,22 +76,26 @@ def course_index(request, org, course, name): 'coursename': name }) - course = modulestore().get_item(location, depth=3) - sections = course.get_children() + course_title = course + course_module = modulestore().get_item(location, depth=3) + sections = course_module.get_children() return render_to_response('overview.html', { - 'context_course': course, + 'context_course': course_module, 'lms_link': lms_link, 'sections': sections, 'course_graders': json.dumps( - CourseGradingModel.fetch(course.location).graders + CourseGradingModel.fetch(course_module.location).graders ), - 'parent_location': course.location, + 'parent_location': course_module.location, 'new_section_category': 'chapter', 'new_subsection_category': 'sequential', 'upload_asset_callback_url': upload_asset_callback_url, 'new_unit_category': 'vertical', - 'category': 'vertical' + 'category': 'vertical', + "course_title": course_title, + "course_id": "/".join([org, course, name]), + "enable_search": settings.MITX_FEATURES.get("COURSE_SEARCH", False) }) diff --git a/cms/envs/common.py b/cms/envs/common.py index 13faf5520e94..ce9c3f1e4638 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -375,7 +375,10 @@ 'django.contrib.admin', # for managing course modes - 'course_modes' + 'course_modes', + + # for course indexing + 'search', ) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 6e4ce460c3a4..4de7bad0f212 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -69,6 +69,7 @@ LMS_BASE = "localhost:8000" MITX_FEATURES['PREVIEW_LMS_BASE'] = "localhost:8000" +MITX_FEATURES['COURSE_SEARCH'] = False REPOS = { 'edx4edx': { diff --git a/cms/static/css/demo.css b/cms/static/css/demo.css new file mode 100644 index 000000000000..e90bbdc05520 --- /dev/null +++ b/cms/static/css/demo.css @@ -0,0 +1,5 @@ +.header:hover{ + background-color: #DEECF7; + color: #5C677A; + cursor: pointer; +} \ No newline at end of file diff --git a/cms/static/js/views/index_courses.js b/cms/static/js/views/index_courses.js new file mode 100644 index 000000000000..dfe5c73e79b0 --- /dev/null +++ b/cms/static/js/views/index_courses.js @@ -0,0 +1,23 @@ +function indexCourses(){ + $("#index-courses").attr("disabled", true); + $("body").css("cursor", "progress"); + var course = ""; + var url = "/index_courseware"; + var courseTitle = $("#index-courses").eq(0).attr("data-course"); + var courseId = $("#course_id").eq(0).attr("value"); + $.ajax({ + type: "POST", + url: url, + data: {"course": courseTitle, "course_id": courseId}, + success: success + }); +} + +function success(){ + $("body").css("cursor", "auto"); + $("#index-courses").attr("disabled", false); +} + +$(document).ready(function() { + $("#index-courses").eq(0).bind("click", indexCourses); +}); diff --git a/cms/templates/overview.html b/cms/templates/overview.html index a8cdfff55419..943c7faea0ac 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -20,7 +20,7 @@ - + @@ -121,6 +121,7 @@

<%block name="content"> +

@@ -140,6 +141,11 @@

${_("Page Actions")}

+ % if search_boolean: + + % endif
diff --git a/cms/urls.py b/cms/urls.py index 1f7da09a8fa0..ab89afc8465f 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -148,6 +148,11 @@ url(r'^auto_auth$', 'student.views.auto_auth'), ) +if settings.MITX_FEATURES.get("COURSE_SEARCH", False): + urlpatterns += ( + url(r'^index_courseware$', 'search.views.index_course', name="index_course"), + ) + if settings.DEBUG: try: from .urls_dev import urlpatterns as dev_urlpatterns diff --git a/common/djangoapps/search/__init__.py b/common/djangoapps/search/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/search/indexing.py b/common/djangoapps/search/indexing.py new file mode 100644 index 000000000000..88947795033b --- /dev/null +++ b/common/djangoapps/search/indexing.py @@ -0,0 +1,395 @@ +""" +General methods and classes for interaction with the Mongo Database and Elasticsearch instance +""" + +import os +import re +import hashlib +import logging +from itertools import chain + +import json +import requests +import lxml.html +from requests.exceptions import RequestException +from django.conf import settings +from pymongo import MongoClient + +log = logging.getLogger(__name__) +MONGO_COURSE_CACHE = {} + +""" +For ElasticSearch's bulk indexing we define a chunk size which is how many documents we will send at once. + +The current number is arbitrary, but the goal is to reduce the number of network requests, and stay robust +against having a single malformed field. + +10 is a pretty decent middle ground. +""" + +CHUNK_SIZE = 10 + + +def flaky_request(method, url, attempts=2, **kwargs): + """ + General exception handling for requests + """ + + for _ in range(attempts): + try: + return requests.request(method, url, **kwargs) + except RequestException: + pass + return None + + +class MalformedDataException(Exception): + """ + Basic Exception raised whenever searchable text cannot be found for an object + """ + + pass + + +class ElasticDatabase(object): + """ + A wrapper for Elastic Search that sits on top of the existent REST api. + + In a broad sense there are two layers in Elastic Search. The top level is + an index. In this implementation indicies represent types of content (transcripts, problems, etc...). + The second level, strictly below indicies, is a type. + + In this implementation types are hashed course ids (SHA1). + + In addition to those two levels of nesting, each individual piece of data has an id associated with it. + Currently the id of each object is a SHA1 hash of its entire id field. + + Each index has "settings" associated with it. These are quite minimal, just specifying the number of + nodes and shards the index is distributed across. + + Each type has a mapping associated with it. A mapping is essentially a database schema with some additional + information surrounding search functionality, such as tokenizers and analyzers. + + Right now these settings are entirely specified through JSON in the settings.json file located within this + directory. Most of the methods in this class serve to instantiate types and indices within the Elastic Search + instance. Additionly there are methods for running basic queries and content indexing. + """ + + def __init__(self): + """ + Instantiates the ElasticDatabase file. + + This includes a url, which should point to the location of the elasticsearch server. + The only other input here is the Elastic Search settings file, which is a JSON file + that should be specified in the application settings file. + + This will also only actually create something if the search feature flag is true. + """ + + if settings.MITX_FEATURES.get("COURSE_SEARCH", False): + self.url = settings.ES_DATABASE + self.index_settings = settings.ES_SETTINGS + else: + log.debug("Search is currently turned off") + + def index_data(self, index, data, type_, id_): + """ + Actually indexes given data at the indicated type and id. + + If no type or id is provided, this will assume that the type and id are + contained within the data object passed to the index_data function in the + hash and type_hash fields. + + Data should be a dictionary that matches the mapping of the given type. + """ + + full_url = "/".join([self.url, index, type_, id_]) + return flaky_request("post", full_url, data=json.dumps(data)) + + def bulk_index(self, all_data): + """ + Allows for bulk indexing of properly formatted json strings. + Example: + {"index": {"_index": "transcript-index", "_type": "course_hash", "_id": "id_hash"}} + {"field1": "value1"...} + + Important: Bulk indexing is newline delimited, make sure newlines are only + between action (line starting with index) and data (line starting with field1) + """ + + url = self.url + "/_bulk" + return flaky_request("post", url, data=all_data) + + +class MongoIndexer(object): + """ + This class is the connection point between Mongo and ElasticSearch. + """ + + def __init__(self, es_instance=ElasticDatabase()): + host = settings.MODULESTORE['default']['OPTIONS']['host'] + port = 27017 + client = MongoClient(host, port) + try: + content_db = settings.CONTENTSTORE["OPTIONS"]['db'] + except AttributeError: + content_db = 'xcontent' + try: + module_db = settings.MODULESTORE['default']['OPTIONS']['db'] + except AttributeError: + module_db = 'xmodule' + self._chunk_collection = client[content_db]["fs.chunks"] + self._module_collection = client[module_db]["modulestore"] + self._es_instance = es_instance + + def _get_bulk_index_item(self, index, data): + """ + Returns a string representing the next indexing action for bulk index + + Format example is in the doc string for bulk_index. Reposted here for clarity: + Example: + {"index": {"_index": "transcript-index", "_type": "course_hash", "_id": "id_hash"}} + {"field1": "value1"...} + """ + + return_string = "" + return_string += json.dumps({"index": {"_index": index, "_type": data["type_hash"], "_id": data["hash"]}}) + return_string += "\n" + return_string += json.dumps(data) + return_string += "\n" + return return_string + + def _get_course_name_from_mongo_module(self, mongo_module): + """ + Given a mongo_module, returns the name for the course element it belongs to + """ + course_element = self._module_collection.find_one({ + "_id.course": mongo_module["_id"]["course"], + "_id.category": "course" + }) + return course_element["_id"]["name"] + + def _get_uuid_from_video_module(self, video_module): + """ + Returns the youtube uuid given a video module. + + Implementation right now is a little hacky since we don't actually have a specific + value for the relevant uuid, though we implicitly refer to all videos by their 1.0 + speed youtube uuids throughout the database. + + Example of the data associated with a video_module: + +