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:
+
+
+ """
+
+ data = video_module.get("definition", {}).get("data", "")
+ if isinstance(data, dict):
+ data = data.get("data", "")
+ if "1.0" in data:
+ uuids = data.split(",")
+ # In the case that we get a value that has any extra information past its closing
+ # quotation, it should be stripped to ensure a valid uuid
+ subtract_suffix = lambda word: word[:word.rfind("\"")] if "\"" in word else word
+ # The colon is kind of a hack to make sure there will always be a second element since
+ # some entries don't have a second entry
+ # Example:
+ speed_map = {(entry + ":").split(":")[0]: (entry + ":").split(":")[1] for entry in uuids}
+ uuid = [subtract_suffix(value) for key, value in speed_map.items() if "1.0" in key]
+ if not uuid:
+ raise MalformedDataException
+ return uuid[0]
+ else:
+ raise MalformedDataException
+
+ def _get_thumbnail_from_video_module(self, video_module):
+ """
+ Return an appropriate binary thumbnail for a given video module
+ """
+
+ data = video_module.get("definition", {}).get("data", "")
+ if "player.youku.com" in data:
+ # Some videos use the youku player, this is just the default youku icon
+ # Youku requires an api key to pull down relevant thumbnails, but
+ # if that is ever present this should be switched. Right now it only applies to two videos.
+ return "https://lh6.ggpht.com/8_h5j6hiFXdSl5atSJDf8bJBy85b3IlzNWeRzOqRurfNVI_oiEG-dB3C0vHRclOG8A=w170"
+ else:
+ uuid = self._get_uuid_from_video_module(video_module)
+ if uuid is None:
+ return "http://img.youtube.com"
+ else:
+ return "http://img.youtube.com/vi/%s/0.jpg" % uuid
+
+ def _get_thumbnail_from_html(self, html):
+ """
+ extracts the first image from the problem if there is an image present
+
+ Otherwise there will be no thumbnail for the problem
+ """
+
+ html_document = lxml.html.fromstring(html)
+ images = html_document.cssselect('img')
+ if len(images) > 0:
+ return images[0].attrib['src']
+ else:
+ return ""
+
+ def _get_searchable_text_from_problem_data(self, mongo_element):
+ """
+ Returns some fascimile of searchable text from a mongo problem element
+
+ The data field from the problem is in weird xml, which is good for functionality, but bad for search
+ """
+
+ data = mongo_element["definition"]["data"]
+ # Grabs all text in paragraph tags.
+ paragraphs = [text for text in re.findall(r"(.*?)
", data)]
+ # Grabs all text between text tags, which is the most common container after paragraph tags.
+ text_groups = [text for text in re.findall(r"(.*?) ", data)]
+ full_text = "%s %s" % (" ".join(paragraphs), " ".join(text_groups))
+ # This gets rid of things like latex strings and other non-human readable escaped passages
+ cleaned_text = re.sub(r"\\(.*?\\)", "", full_text).replace("\\", "")
+ # Removes all lingering tags
+ remove_tags = re.sub(r"<[a-zA-Z0-9/\.\= \"\'_-]+>", "", cleaned_text)
+ if not remove_tags.strip():
+ raise MalformedDataException
+ return remove_tags
+
+ def _find_transcript_for_video_module(self, video_module):
+ """
+ Returns a transcript for a video given the module that contains it.
+
+ The video module should be passed in as an element from some mongo cursor.
+ """
+
+ data = video_module.get("definition", {}).get("data", "")
+ if isinstance(data, dict): # For some reason there are nested versions
+ data = data.get("data", "")
+ if isinstance(data, unicode) is False: # for example videos
+ raise MalformedDataException
+ uuid = self._get_uuid_from_video_module(video_module)
+ name_pattern = re.compile(".*" + uuid + ".*")
+ chunk = (
+ self._chunk_collection.find_one({"files_id.name": name_pattern})
+ )
+ if chunk is None:
+ raise MalformedDataException
+ else:
+ try:
+ chunk_data = chunk["data"].decode('utf-8')
+ if "com.apple.quar" in chunk_data:
+ # This seemingly arbitrary error check brought to you by apple.
+ # This is an obscure, barely documented occurance where apple broke tarballs
+ # and decided to shove error messages into tar metadata which causes this.
+ # https://discussions.apple.com/thread/3145071?start=0&tstart=0
+ raise MalformedDataException
+ else:
+ try:
+ return " ".join(filter(None, json.loads(chunk_data)["text"]))
+ except ValueError:
+ log.error("Transcript for: " + uuid + " is invalid")
+ return chunk_data
+ except UnicodeError:
+ raise MalformedDataException
+
+ def _get_searchable_text(self, mongo_module, type_):
+ """
+ Returns searchable text for a module. Defined for a module only
+ """
+
+ if type_.lower() == "problem":
+ return self._get_searchable_text_from_problem_data(mongo_module)
+ elif type_.lower() == "transcript":
+ return self._find_transcript_for_video_module(mongo_module)
+ else:
+ log.error("%s is not a recognized type", type_)
+ raise NotImplementedError
+
+ def _get_thumbnail(self, mongo_module, type_):
+ """
+ General interface for getting an appropriate thumbnail for a given mongo module
+
+ Currently the only types of modules supported are problems, and transcripts
+ """
+
+ if type_.lower() == "problem":
+ return self._get_thumbnail_from_html(mongo_module["definition"]["data"])
+ elif type_.lower() == "transcript":
+ return self._get_thumbnail_from_video_module(mongo_module)
+ else:
+ log.error("%s is not a recognized type", type_)
+ raise NotImplementedError
+
+ def _get_full_dict(self, mongo_module, type_):
+ """
+ Returns the part of the es schema that is the same for every object.
+ """
+
+ id_ = json.dumps(mongo_module["_id"])
+ org = mongo_module["_id"]["org"]
+ course = mongo_module["_id"]["course"]
+ if not MONGO_COURSE_CACHE.get(course, False):
+ MONGO_COURSE_CACHE[course] = self._get_course_name_from_mongo_module(mongo_module)
+ run = MONGO_COURSE_CACHE[course]
+
+ course_id = "/".join([org, course, run])
+ log.debug(course_id)
+ item_hash = hashlib.sha1(id_).hexdigest()
+ display_name = (
+ mongo_module.get("metadata", {}).get("display_name", "") +
+ " (" + mongo_module["_id"]["course"] + ")"
+ )
+ searchable_text = self._get_searchable_text(mongo_module, type_)
+ thumbnail = self._get_thumbnail(mongo_module, type_)
+ type_hash = hashlib.sha1(course_id).hexdigest()
+ return {
+ "id": id_,
+ "hash": item_hash,
+ "display_name": display_name,
+ "course_id": course_id,
+ "searchable_text": searchable_text,
+ "thumbnail": thumbnail,
+ "type_hash": type_hash
+ }
+
+ def _find_modules_for_course(self, course):
+ """
+ Returns a cursor matching all modules in the given course
+ """
+
+ cursor = self._module_collection.find({"_id.course": course}, timeout=False)
+ # Pymongo's cursors are a little finnicky, so this is just explicitly casting it to a standard generator
+ return (entry for entry in chain(cursor))
+
+ def index_course(self, course):
+ """
+ Indexes all of the searchable content for a course
+ """
+
+ cursor = self._find_modules_for_course(course)
+ counter = 0
+ index_string = ""
+ error_string = ""
+ for item in cursor:
+ category = item["_id"]["category"].lower().strip()
+ data = {}
+ index = ""
+ try:
+ if category == "video":
+ data = self._get_full_dict(item, "transcript")
+ index = "transcript-index"
+ elif category == "problem":
+ data = self._get_full_dict(item, "problem")
+ index = "problem-index"
+ else:
+ continue
+ except MalformedDataException:
+ continue
+ index_string += self._get_bulk_index_item(index, data)
+ error_string += item["_id"]["name"] + "\n"
+ counter += 1
+ if counter % CHUNK_SIZE == 0:
+ index_status_code = self._es_instance.bulk_index(index_string).status_code
+ if index_status_code == 400:
+ log.error("The following bulk index failed: %s", error_string)
+ index_string = ""
+ error_string = ""
diff --git a/common/djangoapps/search/models.py b/common/djangoapps/search/models.py
new file mode 100644
index 000000000000..e96147aa6238
--- /dev/null
+++ b/common/djangoapps/search/models.py
@@ -0,0 +1,205 @@
+"""
+Models for representation of search results
+"""
+
+import json
+import string # pylint: disable=W0402
+
+from django.conf import settings
+import nltk
+import nltk.stem.snowball as snowball
+from nltk.stem import RegexpStemmer
+from guess_language import guessLanguageName
+import logging
+
+import search.sorting
+from xmodule.modulestore import Location
+
+log = logging.getLogger(__name__)
+
+"""
+The soft_max is the number of words at which we stop actively indexing (normally the snippeting works
+on full sentences, so when the soft_max is reached the snippet will stop at the end of that sentence.)
+"""
+
+SOFT_MAX = 50
+
+"""
+The word margin is the maximum number of words past the soft max we allow the snippet to go. This might
+result in truncated snippets.
+"""
+
+WORD_MARGIN = 25
+
+
+class SearchResults(object):
+ """
+ This is a collection of all search results to a query.
+
+ It will automatically sort itself according to a sort parameter passed in as a kwarg.
+ The sort method should be added to search.sorting. The existing sort methods should be
+ decent for outlining how a sort works.
+ """
+
+ def __init__(self, response, **kwargs):
+ """
+ kwargs should be the GET parameters from the original search request
+ filters needs to be a dictionary that maps fields to allowed values
+ """
+ raw_results = json.loads(response.content).get("hits", {"hits": []})["hits"]
+ self.query = kwargs.get("s", "")
+ if not self.query:
+ self.entries = []
+ else:
+ entries = [SearchResult(entry, self.query) for entry in raw_results]
+ sort = kwargs.get("sort", "relevance")
+ self.entries = search.sorting.sort(entries, sort)
+
+ def get_category(self, category):
+ """
+ Returns a subset of all results that match the given category
+
+ If you pass in an empty category the default is to return everything
+ """
+
+ if category == "all" or category is None:
+ return self.entries
+ else:
+ return [entry for entry in self.entries if entry.category == category]
+
+ def get_page(self, page_number, category, results_per_page):
+ """
+ Returns the specific results of a given page in the set of search results with the given category.
+
+ Casts results to dictionary to ensure that Javascript will be able to intepret this without any failings.
+ """
+
+ results = self.get_category(category)
+ sliced_results = results[((page_number - 1) * results_per_page): page_number * results_per_page]
+ return [result.__dict__ for result in sliced_results]
+
+
+class SearchResult(object):
+ """
+ A single element from the Search Results collection
+ """
+
+ def __init__(self, entry, query):
+ self.data = entry["_source"]
+ self.category = json.loads(self.data["id"])["category"]
+ self.url = _return_jump_to_url(self.data)
+ self.score = entry["_score"]
+ if self.data["thumbnail"].startswith("/static/"):
+ self.thumbnail = _get_content_url(self.data, self.data["thumbnail"])
+ else:
+ self.thumbnail = self.data["thumbnail"]
+ language = guessLanguageName(self.data["searchable_text"]).lower()
+ self.snippets = _snippet_generator(self.data["searchable_text"], query[0], language)
+
+
+def _get_content_url(data, static_url):
+ """
+ Generates a real content url for problems specified with static urls
+
+ Nobody seems to know how this works, but this hack works for everything I can find.
+ """
+
+ base_url = "/c4x/%s/%s/asset" % (json.loads(data["id"])["org"], json.loads(data["id"])["course"])
+ addendum = static_url.replace("/static/", "")
+ current = "/".join([base_url, addendum])
+ substring = current[current.find("images/"):].replace("/", "_")
+ substring = current[:current.find("/images")] + "/" + substring
+ return substring
+
+
+def _snippet_generator(transcript, query, language):
+ """
+ This returns a relevant snippet from a given search item with direct matches highlighted.
+
+ The intention is to break the text up into sentences, identify the first occurence of a search
+ term within the text, and start the snippet at the beginning of that sentence.
+
+ e.g: Searching for "history", the start of the snippet for a search result that contains "history"
+ would be the first word of the first sentence containing the word "history"
+
+ If no direct match is found the start of the document is used as the snippet.
+
+ The bold flag determines whether or not the matching terms should be wrapped in a tag.
+
+ For sentence tokenization, we allow a setting, if it is set then we will just use that tokenizer.
+ Otherwise we will try to guess the language of the transcript and use the appropriate punkt tokenizer.
+ If that fails, or we don't have an appropriate tokenizer we will just assume that periods are appropriate
+ sentence delimiters, and if they are things work without condition. Otherwise this tokenizer will just
+ start from the beginning of the transcript.
+ """
+
+ if settings.SENTENCE_TOKENIZER and settings.SENTENCE_TOKENIZER.lower() != "detect":
+ punkt = nltk.data.load(settings.SENTENCE_TOKENIZER)
+ sentences = punkt.tokenize(transcript) # pylint disable=E1103
+ else:
+ try:
+ punkt = nltk.data.load('tokenizers/punkt/%s.pickle' % language)
+ sentences = punkt.tokenize(transcript) # pylint disable=E1103
+ except LookupError:
+ sentences = transcript.split(".")
+
+ query_set = set([_clean(word, language) for word in query.split()])
+ get_sentence_stem_set = lambda sentence: set([_clean(word, language) for word in sentence.split()])
+ stem_match = lambda sentence: bool(query_set.intersection(get_sentence_stem_set(sentence)))
+ snippet_start = next((i for i, sentence in enumerate(sentences) if stem_match(sentence)), 0)
+ response = ""
+ for sentence in sentences[snippet_start:]:
+ if (len(response.split()) + len(sentence.split()) < SOFT_MAX):
+ response += " " + sentence
+ else:
+ response += " " + " ".join(sentence.split()[:WORD_MARGIN])
+ break
+ response = _highlight_matches(query, response, language)
+ return response
+
+
+def _clean(term, language):
+ """
+ Returns a standardized or "cleaned" version of the term
+
+ Specifically casts to lowercase, removes punctuation, and stems.
+
+ stemming is either defined in settings, or discovered through the implied
+ language of the transcript
+ """
+
+ if settings.STEMMER and settings.STEMMER.lower() != "detect":
+ stemmer = getattr(snowball, "%sStemmer" % settings.STEMMER.title())()
+ else:
+ try:
+ stemmer = getattr(snowball, "%sStemmer" % language.title())()
+ except AttributeError:
+ # Blank stemmer to keep things parallel and sensible.
+ stemmer = RegexpStemmer("")
+ if isinstance(term, unicode):
+ punctuation_map = {ord(char): None for char in string.punctuation}
+ rinsed_term = term.translate(punctuation_map)
+ else:
+ rinsed_term = term.translate(None, string.punctuation)
+ return stemmer.stem(rinsed_term.lower())
+
+
+def _highlight_matches(query, response, language):
+ """
+ Highlights all direct matches within given snippet
+ """
+
+ query_set = set([_clean(word, language) for word in query.split()])
+ wrap = lambda word: '%s ' % word
+ return " ".join([wrap(word) if (_clean(word, language) in query_set) else (word) for word in response.split()])
+
+
+def _return_jump_to_url(entry):
+ """
+ Generates the proper jump_to url for a given entry
+ """
+
+ fields = ["tag", "org", "course", "category", "name"]
+ location = Location(*[json.loads(entry["id"])[field] for field in fields])
+ url = '/courses/{0}/jump_to/{1}'.format(entry["course_id"], location)
+ return url
diff --git a/common/djangoapps/search/sorting.py b/common/djangoapps/search/sorting.py
new file mode 100644
index 000000000000..8609a0421e7d
--- /dev/null
+++ b/common/djangoapps/search/sorting.py
@@ -0,0 +1,16 @@
+"""
+A series of sorting functions to help sort search results
+"""
+
+SORTING_DICT = {
+ "relevance": [lambda entry: entry.score, True],
+ "alphabetical": [lambda entry: entry.data.get("display_name", "").lower(), False]
+}
+
+
+def sort(data_list, sorting):
+ """
+ General sort handler, used by SearchResults model for search sorting
+ """
+
+ return sorted(data_list, key=SORTING_DICT[sorting][0], reverse=SORTING_DICT[sorting][1])
diff --git a/common/djangoapps/search/tests/mocks.py b/common/djangoapps/search/tests/mocks.py
new file mode 100644
index 000000000000..429c70b68975
--- /dev/null
+++ b/common/djangoapps/search/tests/mocks.py
@@ -0,0 +1,108 @@
+"""
+A collection of "mocked" resources for simulating servers when running them for unit tests would be too slow
+"""
+
+from collections import namedtuple
+from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
+import threading
+
+
+class StubServer(HTTPServer):
+ """
+ Simple HTTP Stub Server
+ """
+
+ def __init__(self, request_handler, port):
+ address = ('127.0.0.1', port)
+ HTTPServer.__init__(self, address, request_handler)
+ self.start()
+
+ self.requests = []
+ self.request = namedtuple("Request", "request_type path content")
+
+ self.header_dict = {}
+ self.status_code = 200
+ self.content = ""
+
+ def start(self):
+ """
+ Starts the server
+ """
+
+ server_thread = threading.Thread(target=self.serve_forever)
+ server_thread.daemon = True
+ server_thread.start()
+
+ def stop(self):
+ """
+ Cleanly stops the server
+ """
+
+ self.shutdown()
+ self.socket.close()
+
+ def log_request(self, request_type, path, content):
+ """
+ Keeps track of the request and alters content if a search request is launched
+ """
+
+ self.requests.append(self.request(request_type, path, content))
+
+ def set_response(self, header_dict, status_code, content):
+ """
+ Set server response
+ """
+
+ self.header_dict = header_dict
+ self.status_code = status_code
+ self.content = content
+
+
+class StubRequestHandler(BaseHTTPRequestHandler):
+ """
+ Request handler that mocks Elastic Search
+ """
+
+ def do_POST(self): # pylint: disable=C0103
+ """
+ Handling for a POST request
+ """
+
+ self.server.log_request('POST', self.path, self.content())
+ self._send_server_response()
+
+ def do_GET(self): # pylint: disable=C0103
+ """
+ Handling for a GET request
+ """
+
+ self.server.log_request('GET', self.path, self.content())
+ self._send_server_response()
+
+ def do_PUT(self): # pylint: disable=C0103
+ """
+ Handling for a PUT request
+ """
+
+ self.server.log_request('PUT', self.path, self.content())
+ self._send_server_response()
+
+ def content(self):
+ """
+ Returns request content
+ """
+
+ try:
+ length = int(self.headers.getheader('content-length'))
+ except (TypeError, ValueError):
+ return ""
+ self.rfile.read(length)
+
+ def _send_server_response(self):
+ """
+ Sends the Server's current response to the client
+ """
+
+ self.send_response(self.server.status_code)
+ self.end_headers()
+ self.wfile.write(self.server.content)
diff --git a/common/djangoapps/search/tests/test_es.py b/common/djangoapps/search/tests/test_es.py
new file mode 100644
index 000000000000..e1c7ad490c8d
--- /dev/null
+++ b/common/djangoapps/search/tests/test_es.py
@@ -0,0 +1,127 @@
+"""
+Tests for the ElasticDatabase class in indexing
+"""
+
+import json
+
+from django.test import TestCase
+import requests
+from pyfuzz.generator import random_regex
+from django.test.utils import override_settings
+
+from search.indexing import ElasticDatabase, flaky_request
+from mocks import StubServer, StubRequestHandler
+
+
+class PersonalServer(StubServer):
+ """
+ SubServer implementation for ElasticSearch mocking
+ """
+
+ def log_request(self, request_type, path, content):
+ self.requests.append(self.request(request_type, path, content))
+ if request_type == "POST":
+ if path.endswith("/test-index/test-type/"):
+ self.status_code = 201
+ elif path.endswith("_bulk"):
+ self.status_code = 200
+ if request_type == "HEAD":
+ if path.endswith("/test-index/test-type"):
+ self.status_code = 200
+ else:
+ self.status_code = 404
+
+
+@override_settings(ES_DATABASE="http://127.0.0.1:9203")
+@override_settings(MITX_FEATURES={"COURSE_SEARCH": True})
+@override_settings(ES_SETTINGS=open("common/djangoapps/search/tests/test_settings.json").read())
+class EsTest(TestCase):
+ """
+ Test suite for ElasticDatabase class
+ """
+
+ def setUp(self):
+ self.stub = PersonalServer(StubRequestHandler, 9203)
+ es_instance = "http://127.0.0.1:9203"
+ # Making sure that there is actually a running es_instance before testing
+ requests.put(es_instance)
+ self.elastic_search = ElasticDatabase()
+ setup_index(self.elastic_search.url, "test-index", self.elastic_search.index_settings)
+ setup_type(
+ self.elastic_search.url,
+ "test-index",
+ "test-type",
+ "common/djangoapps/search/tests/test_mapping.json"
+ )
+
+ def test_bulk_index(self):
+ test_string = ""
+ test_string += json.dumps({"index": {"_index": "test-index", "_type": "test-type", "_id": "10"}})
+ test_string += "\n"
+ test_string += json.dumps({"searchable_text": "some_text", "test-float": "1.0"})
+ test_string += "\n"
+ success = self.elastic_search.bulk_index(test_string)
+ self.assertEqual(success.status_code, 200)
+ self.assertEqual(success.request.method, "POST")
+ self.assertEqual(success.request.data, test_string)
+
+ def test_index_data(self):
+ fake_data = {
+ "data": "Test String",
+ "hash": random_regex(regex="[a-zA-Z0-9]", length=50),
+ "type_hash": random_regex(regex="[a-zA-Z0-9]", length=50)
+ }
+ response = self.elastic_search.index_data("test-index", fake_data, "test-type", "1234")
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(response.request.method, "POST")
+ self.assertEqual(response.request.data, json.dumps(fake_data))
+
+ def tearDown(self):
+ self.stub.stop()
+
+
+def has_type(url, index, type_):
+ """
+ Same as has_index method, but for a given type
+ """
+
+ full_url = "/".join([url, index, type_])
+ response = flaky_request("head", full_url)
+ if response:
+ return response.status_code == 200
+ else:
+ return False
+
+
+def setup_type(url, index, type_, json_mapping):
+ """
+ Instantiates a type within the Elastic Search instance
+
+ json_mapping should be a dictionary starting at the properties level of a mapping.
+
+ The type level will be added, so if you include it things will break. The purpose of this
+ is to encourage loose coupling between types and mappings for better code
+ """
+
+ full_url = "/".join([url, index, type_]) + "/"
+ with open(json_mapping) as source:
+ dictionary = json.load(source)
+ return requests.post(full_url, data=json.dumps(dictionary))
+
+
+def setup_index(url, index, settings):
+ """
+ Creates a new elasticsearch index, returns the response it gets
+ """
+
+ full_url = "/".join([url, index]) + "/"
+ return flaky_request("put", full_url, data=json.dumps(settings))
+
+
+def delete_index(url, index):
+ """
+ Deletes the index specified, along with all contained types and data
+ """
+
+ full_url = "/".join([url, index])
+ return flaky_request("delete", full_url)
diff --git a/common/djangoapps/search/tests/test_mapping.json b/common/djangoapps/search/tests/test_mapping.json
new file mode 100644
index 000000000000..f9a1858a3a1b
--- /dev/null
+++ b/common/djangoapps/search/tests/test_mapping.json
@@ -0,0 +1,16 @@
+{
+ "mappings": {
+ "searchable_text": {
+ "type": "string",
+ "store": "yes",
+ "index": "analyzed",
+ "term_vector": "with_positions_offsets"
+ },
+
+ "test-float": {
+ "type": "float",
+ "store": "yes"
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/common/djangoapps/search/tests/test_models.py b/common/djangoapps/search/tests/test_models.py
new file mode 100644
index 000000000000..bafdd307a388
--- /dev/null
+++ b/common/djangoapps/search/tests/test_models.py
@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+
+"""
+This is the testing suite for the models within the search module
+"""
+
+import json
+import re
+import collections
+from django.test import TestCase
+from django.test.utils import override_settings
+from pyfuzz.generator import random_regex
+
+from search.models import SearchResults, SearchResult
+from test_mongo import dummy_document
+
+TEST_TEXT = """Lorem ipsum dolor sit amet, consectetur adipisicing elit,
+ sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
+ nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
+ reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
+ deserunt mollit anim id est laborum."""
+
+TEST_GREEK = u"""Σο οι δεύτερον απόσταση απαγωγής ολόκληρο πω. Είχε γιου βάση όλα
+ νου στην όπου σούκ. Ανάλυσης νεόφερτο ας εκ νεανικής τεκμήρια νε θα
+ εξαιτίας δείχνουν. Τη αν ιι έν συμπαίκτης παράδειγμα υποτίθεται τελευταίες.
+ Μου στίχους σαν γίνεται χιούμορ πως αρχίζει κατ σφυγμός συνθήκη. Αναγνώστη
+ προτιμούν σύγχρονες τη κι να κινήματος. Φίλτρο στήθος πει ατο κεί τέλους.
+ Χωρική θέσεις δε χτένας ίμερας έρευνα έμμεση αρ. Προκύψει επίλογοι ιππασίας σαν."""
+
+
+def document_update(original, update):
+ """
+ This is used to selectively update certain parts of a nested dictionary.
+
+ Specifically this is used to update a single field of the source of a mock es object
+ without being forced to copy over the current state of the source
+ """
+
+ for key, value in update.iteritems():
+ if isinstance(value, collections.Mapping):
+ replacement = document_update(original.get(key, {}), value)
+ original[key] = replacement
+ else:
+ original[key] = update[key]
+ return original
+
+
+def dummy_entry(**kwargs):
+ """
+ This creates a fully-fledged fake response entry for a given score
+ """
+
+ id_ = dummy_document("id", ["tag", "org", "course", "category", "name"], "regex", regex="[a-zA-Z0-9]", length=25)
+ source = dummy_document("_source", ["thumbnail", "searchable_text"], "regex", regex="[a-zA-Z0-9]", length=50)
+ string_id = json.dumps(id_["id"])
+ source["_source"].update({"id": string_id, "course_id": random_regex(regex="[a-zA-Z0-9/]", length=50)})
+ document_update(source, kwargs)
+ return source
+
+
+class FakeResponse(object):
+ """
+ Fake minimal response, just wrapping a given dictionary in a response-like object
+ """
+
+ def __init__(self, dictionary):
+ self.content = json.dumps(dictionary)
+
+
+@override_settings(SENTENCE_TOKENIZER="tokenizers/punkt/english.pickle")
+@override_settings(STEMMER="ENGLISH")
+class ModelTest(TestCase):
+ """
+ Tests SearchResults and SearchResult models as well as associated helper functions
+ """
+
+ def test_search_result_init(self):
+ check = SearchResult(dummy_entry(_score = 1.0), ["fake-query"])
+ self.assertTrue(bool(re.match(r"^[a-zA-Z0-9]+$", check.snippets)))
+ self.assertTrue(check.url.startswith("/courses"))
+ self.assertTrue("/jump_to/" in check.url)
+ self.assertEqual(check.category, json.loads(check.data["id"])["category"])
+
+ def test_snippet_generation(self):
+ document = dummy_entry(_score=1.0, _source={"searchable_text": TEST_TEXT})
+ result = SearchResult(document, [u"quis nostrud"])
+ self.assertTrue(result.snippets.startswith("Ut enim ad minim"))
+ self.assertTrue(result.snippets.strip().endswith("anim id est laborum."))
+ self.assertTrue('quis ' in result.snippets)
+ self.assertTrue('nostrud ' in result.snippets)
+
+ @override_settings(SENTENCE_TOKENIZER="DETECT")
+ @override_settings(STEMMER="DETECT")
+ def test_language_detection(self):
+ document = dummy_entry(_score=1.0, _source={"searchable_text": TEST_GREEK})
+ result = SearchResult(document, [u"νου στην όπου"])
+ self.assertTrue(result.snippets.startswith(u"Είχε γιου"))
+
+ def test_search_results(self):
+ scores = [1.0, 5.2, 2.0, 123.2]
+ hits = [dummy_entry(_score=score) for score in scores]
+ full_return = FakeResponse({"hits": {"hits": hits}})
+ results = SearchResults(full_return, s=["fake query"], sort="relevance")
+ self.assertTrue(all([isinstance(result, SearchResult) for result in results.entries]))
+ self.assertEqual(["fake query"], results.query)
+ scores = [entry.score for entry in results.entries]
+ self.assertEqual([123.2, 5.2, 2.0, 1.0], scores)
+
+ def test_get_content_url(self):
+ document = dummy_entry(_score=1.0)
+ id_ = json.dumps({
+ "org": "test-org",
+ "course": "test-course",
+ "category": "fake-category",
+ "tag": "fake-tag",
+ "name": "fake-name"
+ })
+ document["_source"]["id"] = id_
+ document["_source"]['thumbnail'] = "/static/images/test/image/url.jpg"
+ result = SearchResult(document, "fake query")
+ expected_content_url = "/c4x/test-org/test-course/asset/images_test_image_url.jpg"
+ self.assertEqual(expected_content_url, result.thumbnail)
diff --git a/common/djangoapps/search/tests/test_mongo.py b/common/djangoapps/search/tests/test_mongo.py
new file mode 100644
index 000000000000..363e1a747b0f
--- /dev/null
+++ b/common/djangoapps/search/tests/test_mongo.py
@@ -0,0 +1,169 @@
+"""
+Test suite for the MongoIndexer class in es_requests
+"""
+
+import json
+
+from django.test import TestCase
+from django.test.utils import override_settings
+
+from pymongo import MongoClient
+from pyfuzz.generator import random_item
+
+from search.indexing import MongoIndexer, MalformedDataException
+
+
+def dummy_document(key, values, data_type, **kwargs):
+ """
+ Returns a document matching the key to a dictionary mapping each value to a random string
+
+ kwargs is passed directly to the random_item method of pyfuzz
+ """
+
+ dummy_data = {}
+ dummy_data[key] = {value: random_item(data_type, **kwargs) for value in values}
+ return dummy_data
+
+
+class MongoTest(TestCase):
+ """
+ Test suite for the MongoIndexer class
+ """
+
+ @override_settings(CONTENTSTORE={'OPTIONS': {'db': 'test-content'}})
+ @override_settings(MODULESTORE={'default': {'OPTIONS': {'db': 'test-module', 'host': 'localhost'}}})
+ def setUp(self):
+ self.client = MongoClient('localhost', 27017)
+ # Create test databases
+ dummy = {"dummy": True}
+ self.test_content = self.client["test-content"]
+ self.test_module = self.client["test-module"]
+ # Create expected collections
+ self.chunk_collection = self.test_content["fs.chunks"]
+ self.chunk_collection.insert(dummy)
+ self.file_collection = self.test_content["fs.files"]
+ self.file_collection.insert(dummy)
+ self.module_collection = self.test_module["modulestore"]
+ self.module_collection.insert(dummy)
+ self.indexer = MongoIndexer()
+
+ def test_find_module_for_course(self):
+ id_ = dummy_document("_id", ["tag", "org", "course", "category", "name"], "ascii", length=20)
+ self.module_collection.insert(id_)
+ cursor = self.indexer._find_modules_for_course(id_["_id"]["course"])
+ self.assertEquals(cursor.next()["_id"], id_["_id"])
+
+ def test_find_module_transcript(self):
+ video_module = dummy_document("definition", ["data"], "ascii", length=200)
+ test_string = ''
+ video_module["definition"]["data"] += test_string
+ test_transcript = {"text": random_item("ascii", length=50)}
+ test_document = {"files_id": {"name": "dJvsFg10JY"}, "data": json.dumps(test_transcript)}
+ self.chunk_collection.insert(test_document)
+ transcript = self.indexer._find_transcript_for_video_module(video_module).encode("utf-8", "ignore")
+ self.assertEquals(transcript.replace(" ", ""), test_transcript["text"].replace(" ", ""))
+
+ test_bad_transcript = {"definition": {"data": 10}}
+ success = False
+ try:
+ self.indexer._find_transcript_for_video_module(test_bad_transcript), [""]
+ except MalformedDataException:
+ success = True
+ self.assertTrue(success)
+
+ def test_problem_text(self):
+ test_text = "This is a test
and so is this "
+ document = {"definition": {"data": test_text}}
+ check = self.indexer._get_searchable_text_from_problem_data(document)
+ self.assertEquals(check, "This is a test and so is this")
+
+ bad_document = {"definition": {"data": "@#@%^%#$afsdkjjl@#!$%"}}
+ success = False
+ try:
+ self.indexer._get_searchable_text_from_problem_data(bad_document)
+ except MalformedDataException:
+ success = True
+ self.assertTrue(success)
+
+ def test_youku_video(self):
+ document = {"definition": {"data": "player.youku.com"}}
+ image = self.indexer._get_thumbnail_from_video_module(document)
+ url = "https://lh6.ggpht.com/8_h5j6hiFXdSl5atSJDf8bJBy85b3IlzNWeRzOqRurfNVI_oiEG-dB3C0vHRclOG8A=w170"
+ self.assertEquals(image, url)
+
+ def test_bad_video(self):
+ document = {"definition": {"data": ""}}
+ success = False
+ try:
+ self.indexer._get_thumbnail_from_video_module(document)
+ except MalformedDataException:
+ success = True
+ self.assertTrue(success)
+
+ def test_good_thumbnail(self):
+ test_string = ''
+ document = {"definition": {"data": test_string}}
+ image = self.indexer._get_thumbnail_from_video_module(document)
+ url = "http://img.youtube.com/vi/dJvsFg10JY/0.jpg"
+ self.assertEquals(url, image)
+
+ def test_html_thumbnail(self):
+ success = True
+ try:
+ self.indexer._get_thumbnail_from_html("Test
")
+ except:
+ success = False
+ self.assertTrue(success)
+
+ def test_get_searchable_text(self):
+ problem_test_text = "This is a test
and so is this "
+ problem_document = {"definition": {"data": problem_test_text}}
+ problem_test = self.indexer._get_searchable_text(problem_document, "problem")
+ self.assertEquals(problem_test, "This is a test and so is this")
+
+ video_module = dummy_document("definition", ["data"], "ascii", length=200)
+ test_string = ''
+ video_module["definition"]["data"] += test_string
+ test_transcript = {"text": random_item("ascii", length=50)}
+ test_document = {"files_id": {"name": "dJvsFg10JY"}, "data": json.dumps(test_transcript)}
+ self.chunk_collection.insert(test_document)
+ transcript = self.indexer._get_searchable_text(video_module, "transcript").encode("utf-8", "ignore")
+ self.assertEquals(transcript.replace(" ", ""), test_transcript["text"].replace(" ", ""))
+
+ def test_bulk_index_item(self):
+ data = {"type_hash": "test type hash", "hash": "test hash"}
+ bulk_index = self.indexer._get_bulk_index_item("test-index", data)
+ action = json.loads(bulk_index.split("\n")[0])
+ self.assertEquals(action["index"]["_index"], "test-index")
+ self.assertEquals(action["index"]["_type"], "test type hash")
+
+ def test_index_course_problem(self):
+ document = dummy_document("_id", ["org", "name"], "regex", regex="[a-zA-Z0-9]", length=50)
+ document["_id"].update({"category": "problem", "course": "test-course"})
+ asset_string = "Test
"
+ document.update({"definition": {"data": asset_string}})
+ self.module_collection.insert(document)
+ course_document = {"_id": {"category": "course", "course": document["_id"]["course"], "name": "test_course"}}
+ self.module_collection.insert(course_document)
+ self.indexer.index_course("test-course")
+
+ def test_index_course_pdf(self):
+ document = dummy_document("_id", ["org"], "regex", regex="[a-zA-Z0-9]", length=50)
+ document["_id"].update({"category": "html", "course": "test-course"})
+ random_asset_name = random_item("regex", regex="[a-zA-Z0-9]", length=50)
+ asset_string = "/asset/%s.pdf" % random_asset_name
+ document.update({"definition": {"data": asset_string}})
+ self.module_collection.insert(document)
+
+ course_document = {"_id": {"category": "course", "course": document["_id"]["course"], "name": "test_course"}}
+ self.module_collection.insert(course_document)
+ check = self.indexer.index_course("test-course")
+ self.assertEquals(check, None)
+
+ def tearDown(self):
+ self.test_content.drop_collection("fs.chunks")
+ self.test_content.drop_collection("fs.files")
+ self.client.drop_database("test-content")
+
+ self.test_module.drop_collection("modulestore")
+ self.client.drop_database("test-module")
diff --git a/common/djangoapps/search/tests/test_settings.json b/common/djangoapps/search/tests/test_settings.json
new file mode 100644
index 000000000000..2c63c0851048
--- /dev/null
+++ b/common/djangoapps/search/tests/test_settings.json
@@ -0,0 +1,2 @@
+{
+}
diff --git a/common/djangoapps/search/tests/test_sorting.py b/common/djangoapps/search/tests/test_sorting.py
new file mode 100644
index 000000000000..206e8719ae45
--- /dev/null
+++ b/common/djangoapps/search/tests/test_sorting.py
@@ -0,0 +1,38 @@
+"""
+Test suite for various sorting methods in sorting.sort
+"""
+
+from django.test import TestCase
+from pyfuzz.generator import random_item
+
+import search.sorting as sorting
+
+
+class SortingTest(TestCase):
+ """
+ This contains all of the current sorting tests.
+ """
+
+ def test_alphabetical_sort(self):
+ test_list = ["One", "Two", "3.three", "four", "Five"]
+ dummy_results = [DummyResult(name, i) for i, name in enumerate(test_list)]
+ sorted_list = sorting.sort(dummy_results, "alphabetical")
+ sorted_results = [result.data["display_name"] for result in sorted_list]
+ self.assertEqual(sorted_results, ['3.three', 'Five', 'four', "One", 'Two'])
+
+ def test_score_sort(self):
+ test_scores = [10, 1.1, 48391023, 32.123678, 2939.3434, 0.0]
+ dummy_results = [DummyResult(random_item("ascii", length=20), score) for score in test_scores]
+ sorted_list = sorting.sort(dummy_results, "relevance")
+ sorted_results = [result.score for result in sorted_list]
+ self.assertEqual(sorted_results, [48391023, 2939.3434, 32.123678, 10, 1.1, 0.0])
+
+
+class DummyResult():
+ """
+ This generates a minimal fake result to test current active sort methods
+ """
+
+ def __init__(self, display_name, score):
+ self.score = score
+ self.data = {"display_name": display_name}
diff --git a/common/djangoapps/search/tests/test_views.py b/common/djangoapps/search/tests/test_views.py
new file mode 100644
index 000000000000..c5eadffe15c3
--- /dev/null
+++ b/common/djangoapps/search/tests/test_views.py
@@ -0,0 +1,121 @@
+"""
+Basic test for views in search
+"""
+
+import django_future.csrf
+
+class MockCsrfProtection(object):
+ """
+ A replacement for django's default csrf protection
+
+ Has to be initialized here because the decorators will be applied as soon as a module is imported,
+ which sadly means that standard patching doesn't work.
+ """
+
+ __name__ = "MockCsrfProtection"
+
+ def __init__(self, func):
+ self.func = func
+
+ def __call__(self, *args, **kwargs):
+ return self.func(*args, **kwargs)
+
+django_future.csrf.ensure_csrf_cookie = MockCsrfProtection
+
+from django.http import HttpRequest
+from django.test import TestCase
+from django.test.utils import override_settings
+from django.test.client import RequestFactory
+from xmodule.modulestore.tests.factories import CourseFactory
+from django.contrib.auth.models import AnonymousUser
+from mock import Mock, patch
+
+import search.views as views
+from search.indexing import MongoIndexer
+from mocks import StubServer, StubRequestHandler
+
+
+def mock_render_to_response(template, context): # pylint: disable=W0613
+ """
+ Stand-in for testing allowing a quick check
+ """
+
+ return context
+
+
+def mock_get_course_with_access(*args): # pylint: disable=W0613
+ """
+ Another testing stand-in for course authentication
+
+ The purpose of this right now is to ensure that this method won't error in tests.
+ """
+
+ return "fake-course"
+
+
+def mock_course_indexing(course):
+ """
+ This is a simple stand in for the course-indexing endpoint, just to ensure that transmission is smooth.
+ """
+
+ return course
+
+
+class PersonalServer(StubServer):
+ """
+ SubServer implementation for simple search mocking
+ """
+
+ def log_request(self, request_type, path, content):
+ self.requests.append(self.request(request_type, path, content))
+ if path.endswith("_search"):
+ self.content = "{}"
+
+
+class MockMongoIndexer(MongoIndexer):
+ """
+ Minimal version of the MongoIndexer that rewrites the relevant methods.
+ """
+
+ def __init__(self):
+ pass
+
+ def index_course(self, course):
+ return course
+
+
+@override_settings(ES_DATABASE="http://127.0.0.1:9203")
+@override_settings(MITX_FEATURES={"COURSE_SEARCH": True})
+@patch('search.views.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True))
+@patch('search.views.get_course_with_access', Mock(side_effect=mock_get_course_with_access, autospec=True))
+class ViewTest(TestCase):
+ """
+ Basic test class for base view case. A small test, but one that adresses some blind spots
+ """
+
+ def setUp(self):
+ self.stub = PersonalServer(StubRequestHandler, 9203)
+ self.request_factory = RequestFactory()
+
+ def test_search_endpoint(self):
+ request = HttpRequest()
+ request.method = "GET"
+ request.user = AnonymousUser()
+ response = views.search(request, 'fake/course/id')
+ self.assertTrue(isinstance(response['search_results']['all'], dict))
+ self.assertEqual(response['search_results']['all']['total'], 0)
+
+ @patch('search.views.MongoIndexer', Mock(side_effect=MockMongoIndexer, autospec=True))
+ def test_index_course(self):
+ request = self.request_factory.post(
+ '/index_courseware',
+ data={"course": "fake-course", "course_id": "fake/course/test"}
+ )
+ request.user = AnonymousUser()
+ response = views.index_course(request)
+ self.assertEqual(response.status_code, 204)
+ self.assertTrue(response.has_header("content-type"))
+ self.assertEqual(response["course"], "fake-course")
+
+ def tearDown(self):
+ self.stub.stop()
diff --git a/common/djangoapps/search/views.py b/common/djangoapps/search/views.py
new file mode 100644
index 000000000000..73657f36d0bc
--- /dev/null
+++ b/common/djangoapps/search/views.py
@@ -0,0 +1,128 @@
+"""
+View functions and interface for search functionality
+"""
+
+import logging
+import hashlib
+import json
+import math
+
+import requests
+from django.conf import settings
+from django.http import HttpResponseBadRequest, HttpResponse, Http404
+from mitxmako.shortcuts import render_to_response
+from django_future.csrf import ensure_csrf_cookie
+
+from courseware.courses import get_course_with_access
+from search.models import SearchResults
+from search.indexing import MongoIndexer
+
+
+CONTENT_TYPES = set(["transcript", "problem"])
+FILTER_TYPES = set(["all", "video", "problem"])
+RESULTS_PER_PAGE = 10
+PAGE_PRELOAD_SPAN = 2
+log = logging.getLogger(__name__)
+
+
+@ensure_csrf_cookie
+def search(request, course_id):
+ """
+ Returns search results within course_id from request.
+
+ Request should contain the query string in the "s" parameter.
+
+ If user doesn't have access to the course, get_course_with_access automatically 404s
+ """
+
+ page = int(request.GET.get("page", 1))
+ current_filter = request.GET.get("filter", "all")
+ course = get_course_with_access(request.user, course_id, 'load')
+ search_results = _find(request, course_id)
+ full_context = {
+ "search_results": _construct_search_context(search_results, page, current_filter),
+ "course": course,
+ "old_query": request.GET.get("s", "*.*"),
+ "course_id": course_id,
+ "current_filter": current_filter,
+ "page": page
+ }
+ return render_to_response("search_templates/results.html", full_context)
+
+
+@ensure_csrf_cookie
+def index_course(request):
+ """
+ Indexes the searchable material currently within the course
+
+ Is called via AJAX from Studio, and doesn't render any templates.
+ """
+
+ course = get_course_with_access(request.user, request.POST["course_id"], 'staff')
+ indexer = MongoIndexer()
+ if "course" in request.POST:
+ indexer.index_course(request.POST["course"])
+ response = HttpResponse(status=204)
+ response['course'] = request.POST["course"]
+ return response
+ else:
+ return HttpResponseBadRequest()
+
+
+def _find(request, course_id):
+ """
+ Method in charge of getting search results and associated metadata
+ """
+
+ try:
+ database = settings.ES_DATABASE
+ except AttributeError: # If settings has no ES_DATABASE
+ raise Http404
+ query = request.GET.get("s", "*.*")
+ full_query_data = {
+ "query": {
+ "query_string": {
+ "default_field": "searchable_text",
+ "query": query,
+ "analyzer": "standard"
+ },
+ },
+ "size": "1000"
+ }
+ index = ",".join([content + "-index" for content in CONTENT_TYPES])
+
+ course_hash = hashlib.sha1(course_id).hexdigest()
+ base_url = "/".join([database, index, course_hash])
+ base_url += "/_search"
+ response = requests.get(base_url, data=json.dumps(full_query_data))
+ return SearchResults(response, **request.GET)
+
+
+def _construct_search_context(search_results, page, this_filter):
+ """
+ Takes the entirety of the results from ElasticSearch and constructs the JSON needed by the template
+
+ Specifically, grabs two pages on either side of the current page within the current filter,
+ also associates the total number of results with all other filter types.
+ """
+
+ total_results = {filter_: {} for filter_ in FILTER_TYPES}
+ functional_results_length = len(search_results.get_category(this_filter))
+ total_pages = int(math.ceil(float(functional_results_length) / RESULTS_PER_PAGE))
+
+ current_filter_pages = lambda range_generator: {
+ page: search_results.get_page(page, this_filter, RESULTS_PER_PAGE) for page in range_generator
+ }
+
+ page_span = xrange(max(1, page - PAGE_PRELOAD_SPAN), min(total_pages + 1, page + PAGE_PRELOAD_SPAN + 1))
+ total_results[this_filter]["results"] = current_filter_pages(page_span)
+ total_results[this_filter]["total"] = functional_results_length
+
+ results_total = lambda filter_: {
+ "total": len(search_results.get_category(filter_)),
+ "results": {}
+ }
+
+ other_filters = FILTER_TYPES - set([this_filter])
+ total_results.update({filter_: results_total(filter_) for filter_ in other_filters})
+ return total_results
diff --git a/common/djangoapps/taxonomy_creation/phrase_generation.py b/common/djangoapps/taxonomy_creation/phrase_generation.py
new file mode 100644
index 000000000000..d4b00cf89726
--- /dev/null
+++ b/common/djangoapps/taxonomy_creation/phrase_generation.py
@@ -0,0 +1,23 @@
+"""
+Given a piece of content this aims to generate a relevant set of search terms to generate mediawiki-based tags
+"""
+
+from pymarkov import markov
+from nltk.tokenize import word_tokenize, sent_tokenize
+
+
+def _get_master_dict(content):
+ """
+ Given a piece of written content, creates a markov dictionary sentence by sentence
+ """
+
+ return markov.train(sent_tokenize(content), 1, split_callback=word_tokenize)
+
+
+def _get_frequency_distribution(markov_dict):
+ """
+ Takes the master markov dictionary and returns a counter distribution of the entire set
+ """
+
+ values = lambda counter: (value for key, value in counter.iteritems())
+ distribution = [values(entry) for key,entry in markov_dict[1].iteritems()]
\ No newline at end of file
diff --git a/common/djangoapps/taxonomy_creation/scraping.py b/common/djangoapps/taxonomy_creation/scraping.py
new file mode 100644
index 000000000000..5e9b39171628
--- /dev/null
+++ b/common/djangoapps/taxonomy_creation/scraping.py
@@ -0,0 +1,77 @@
+import requests
+from requests.exceptions import ConnectionError
+import lxml
+import lxml.html
+from lxml.cssselect import CSSSelector
+
+from py2neo import neo4j
+
+db = neo4j.GraphDatabaseService(neo4j.DEFAULT_URI)
+
+def _grab_article_links(relative_url):
+ """
+ Given a relative Wikipedia article url, will grab all links within that article
+ """
+
+ wikipedia_url = "http://www.wikipedia.org%s" % relative_url
+ page = requests.get(wikipedia_url).content
+ selector = CSSSelector('a')
+ html = lxml.html.fromstring(page)
+ links = [item.get('href', "") for item in selector(html)]
+ articles = set(link for link in links if _is_an_article(link))
+ return articles
+
+
+def _is_an_article(link):
+ """
+ Given a link from a page, determines whether or not the link is to another wikipedia article
+ """
+ on_wikipedia = link.startswith("/wiki/")
+ # Wikipedia uses the colon as a reserved pseudo-mimetype indicator for non-articles
+ is_not_reserved_page = not ":" in link
+ is_not_main_page = not link.endswith("/Main_Page")
+ return on_wikipedia and is_not_reserved_page and is_not_main_page
+
+
+def _create_node_from_article(link, index):
+ """
+ Given the href stub and the index to be inserted into, update the database to
+ """
+
+ absolute_url = "http://www.wikipedia.org%s" % link
+ try:
+ article = lxml.html.fromstring(requests.get(absolute_url).content)
+ except ConnectionError:
+ article = lxml.html.fromstring(requests.get(absolute_url).content)
+ selector = CSSSelector('#firstHeading span')
+ title = selector(article)[0].text_content()
+ return index.get_or_create("title", title, {"title": title, "url": absolute_url})
+
+def _add_all_nodes_to_neo4j(base_link, index):
+ """
+ Given a base link, adds all nodes representing articles linked to from that base linke to neo4j
+ """
+
+ node_index = db.get_or_create_index(neo4j.Node, index)
+ base_node = _create_node_from_article(base_link, node_index)
+ print base_link
+ for link in _grab_article_links(base_link):
+ print link
+ current_node = _create_node_from_article(link, node_index)
+ db.get_or_create_relationships((base_node, "LINKS_TO", current_node))
+
+def _extend_node_mapping(base_link, index):
+ """
+ Given a base link, scrapes all nodes linked to that node and adds the information to the database.
+
+ Worth noting that this is likely to be pretty slow.
+ """
+
+ node_index = db.get_or_create_index(neo4j.Node, index)
+ base_node = _create_node_from_article(base_link, node_index)
+ relative_url = lambda absolute_url: "/%s/%s" % (absolute_url.split("/")[-2], absolute_url.split("/")[-1])
+ for relationship in base_node.match(bidirectional=True):
+ linked_node = relationship.end_node
+ _add_all_nodes_to_neo4j(relative_url(linked_node["url"]), index)
+
+_extend_node_mapping("/wiki/Science", "article")
\ No newline at end of file
diff --git a/common/static/css/search.css b/common/static/css/search.css
new file mode 100644
index 000000000000..86af68a8b56a
--- /dev/null
+++ b/common/static/css/search.css
@@ -0,0 +1,190 @@
+.result-header{
+ text-align: center;
+ word-wrap: normal;
+ padding-top: 20px;
+ width: 40em;
+}
+
+.result-container{
+ margin: 0 auto;
+ clear: both;
+}
+
+.snippet{
+ color: #666666;
+ font-family: "Open Sans", "Arial";
+ font-weight: 300;
+ margin-left: 10px;
+ width: 30em;
+ text-align: center;
+ overflow-y: hidden;
+}
+
+.result-snippets{
+ float: left;
+}
+
+.thumbnail-wrapper{
+ float:left;
+ overflow:hidden;
+ width: 25%;
+}
+
+.result-thumbnail{
+ width: 200px;
+ overflow-x: hidden;
+ float:left;
+}
+
+.result-title{
+ text-align: center;
+ font-size: 20px;
+}
+
+.page-number{
+ width: 30px;
+}
+
+.search-icon{
+ text-align: right;
+}
+
+.video-image:before{
+ content: url("../images/sequence-nav/video-icon-normal.png");
+ float:left;
+ padding-left: 10px;
+ padding-right: 5px;
+}
+
+.problem-image:before{
+ content: url("../images/sequence-nav/list-icon-normal.png");
+ float:left;
+ padding-left: 10px;
+ padding-right: 5px;
+}
+
+.search-container{
+ float: left;
+ margin-left: 30px;
+}
+.text-wrapper{
+ border-radius: 3px;
+ color: #555;
+ display: block;
+ text-align: center;
+ padding: 10px 13px 12px;
+ font-size: 10px;
+ font-weight: bold;
+ text-decoration: none;
+}
+
+.search-bar:hover{
+ cursor: pointer;
+}
+
+a.search-bar{
+ -moz-animation-duration: 0.75s;
+ -webkit-animation-duration: 0.75s;
+}
+
+#search-wrapper{
+ -webkit-animation-duration: 0.5s;
+ -moz-animation-duration: 0.5s;
+ -webkit-animation-delay: 0s;
+ -moz-animation-delay: 0s;
+}
+
+#searchbox{
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+}
+
+ #menu-container{
+ background-color: #666666;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+ width:20%;
+ float:left;
+ background: #efefef;
+ border-radius: 2px;
+ display: block;
+ padding: 0;
+ margin: 3% 0 2.5% 1.5%;
+}
+
+.filter-menu{
+ background: #f7f7f8;
+ border-radius: 2px;
+ border: 1px solid #d8d8d8;
+ padding: 0;
+ margin:3px;
+}
+
+.header-bar{
+ border-bottom: 1px solid #eeeeee;
+ padding-left: 5px;
+ font-weight: bold;
+ list-style-type: none;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ font-family: 'Open Sans', 'Arial';
+}
+
+.body-bar{
+ border-bottom: 1px solid #eeeeee;
+ padding-left: 15px;
+ list-style-type: none;
+ padding-top: 7px;
+ padding-bottom: 7px;
+ font-family: 'Open Sans', 'Arial';
+}
+
+.filter-item{
+ padding-top: 7px;
+ padding-bottom: 7px;
+}
+
+li:hover{
+ cursor: pointer;
+ background: #ffffff;
+}
+
+._currentFilter{
+ background: #ffffff;
+}
+
+.count{
+ float:right;
+ padding: 1px 5px;
+ font-size: 13px;
+ font-weight: bold;
+ color: #999999;
+ background: #eeeeee;
+ border-radius: 2px;
+ margin-right: 5px;
+}
+
+.no-results{
+ padding-top: 8%;
+}
+
+.context{
+ text-align: center;
+ color: #9f9f9f;
+}
+
+.search-context{
+ color: #d0d0d0;
+ text-align:center;
+}
+
+.pagination-stub{
+ margin-left: 11em;
+}
+
+.pagination-stub ul a{
+ line-height:24px;
+ text-align: center;
+}
diff --git a/common/static/css/vendor/animate-custom.css b/common/static/css/vendor/animate-custom.css
new file mode 100644
index 000000000000..137879b6b2f7
--- /dev/null
+++ b/common/static/css/vendor/animate-custom.css
@@ -0,0 +1,494 @@
+@charset "UTF-8";
+/*
+Animate.css - http://daneden.me/animate
+Licensed under the MIT license
+
+Copyright (c) 2013 Daniel Eden
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+*/
+
+.animated{-webkit-animation-fill-mode:both;-moz-animation-fill-mode:both;-ms-animation-fill-mode:both;-o-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:1s;-moz-animation-duration:1s;-ms-animation-duration:1s;-o-animation-duration:1s;animation-duration:1s;}.animated.hinge{-webkit-animation-duration:2s;-moz-animation-duration:2s;-ms-animation-duration:2s;-o-animation-duration:2s;animation-duration:2s;}@-webkit-keyframes fadeIn {
+ 0% {opacity: 0;} 100% {opacity: 1;}
+}
+
+@-moz-keyframes fadeIn {
+ 0% {opacity: 0;}
+ 100% {opacity: 1;}
+}
+
+@-o-keyframes fadeIn {
+ 0% {opacity: 0;}
+ 100% {opacity: 1;}
+}
+
+@keyframes fadeIn {
+ 0% {opacity: 0;}
+ 100% {opacity: 1;}
+}
+
+.fadeIn {
+ -webkit-animation-name: fadeIn;
+ -moz-animation-name: fadeIn;
+ -o-animation-name: fadeIn;
+ animation-name: fadeIn;
+}
+@-webkit-keyframes fadeInUp {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -webkit-transform: translateY(0);
+ }
+}
+
+@-moz-keyframes fadeInUp {
+ 0% {
+ opacity: 0;
+ -moz-transform: translateY(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -moz-transform: translateY(0);
+ }
+}
+
+@-o-keyframes fadeInUp {
+ 0% {
+ opacity: 0;
+ -o-transform: translateY(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -o-transform: translateY(0);
+ }
+}
+
+@keyframes fadeInUp {
+ 0% {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.fadeInUp {
+ -webkit-animation-name: fadeInUp;
+ -moz-animation-name: fadeInUp;
+ -o-animation-name: fadeInUp;
+ animation-name: fadeInUp;
+}
+@-webkit-keyframes fadeInDown {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -webkit-transform: translateY(0);
+ }
+}
+
+@-moz-keyframes fadeInDown {
+ 0% {
+ opacity: 0;
+ -moz-transform: translateY(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -moz-transform: translateY(0);
+ }
+}
+
+@-o-keyframes fadeInDown {
+ 0% {
+ opacity: 0;
+ -o-transform: translateY(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -o-transform: translateY(0);
+ }
+}
+
+@keyframes fadeInDown {
+ 0% {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.fadeInDown {
+ -webkit-animation-name: fadeInDown;
+ -moz-animation-name: fadeInDown;
+ -o-animation-name: fadeInDown;
+ animation-name: fadeInDown;
+}
+@-webkit-keyframes fadeInLeft {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateX(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -webkit-transform: translateX(0);
+ }
+}
+
+@-moz-keyframes fadeInLeft {
+ 0% {
+ opacity: 0;
+ -moz-transform: translateX(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -moz-transform: translateX(0);
+ }
+}
+
+@-o-keyframes fadeInLeft {
+ 0% {
+ opacity: 0;
+ -o-transform: translateX(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -o-transform: translateX(0);
+ }
+}
+
+@keyframes fadeInLeft {
+ 0% {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.fadeInLeft {
+ -webkit-animation-name: fadeInLeft;
+ -moz-animation-name: fadeInLeft;
+ -o-animation-name: fadeInLeft;
+ animation-name: fadeInLeft;
+}
+@-webkit-keyframes fadeInRight {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateX(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -webkit-transform: translateX(0);
+ }
+}
+
+@-moz-keyframes fadeInRight {
+ 0% {
+ opacity: 0;
+ -moz-transform: translateX(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -moz-transform: translateX(0);
+ }
+}
+
+@-o-keyframes fadeInRight {
+ 0% {
+ opacity: 0;
+ -o-transform: translateX(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ -o-transform: translateX(0);
+ }
+}
+
+@keyframes fadeInRight {
+ 0% {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.fadeInRight {
+ -webkit-animation-name: fadeInRight;
+ -moz-animation-name: fadeInRight;
+ -o-animation-name: fadeInRight;
+ animation-name: fadeInRight;
+}
+@-webkit-keyframes fadeOut {
+ 0% {opacity: 1;}
+ 100% {opacity: 0;}
+}
+
+@-moz-keyframes fadeOut {
+ 0% {opacity: 1;}
+ 100% {opacity: 0;}
+}
+
+@-o-keyframes fadeOut {
+ 0% {opacity: 1;}
+ 100% {opacity: 0;}
+}
+
+@keyframes fadeOut {
+ 0% {opacity: 1;}
+ 100% {opacity: 0;}
+}
+
+.fadeOut {
+ -webkit-animation-name: fadeOut;
+ -moz-animation-name: fadeOut;
+ -o-animation-name: fadeOut;
+ animation-name: fadeOut;
+}
+@-webkit-keyframes fadeOutUp {
+ 0% {
+ opacity: 1;
+ -webkit-transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateY(-20px);
+ }
+}
+@-moz-keyframes fadeOutUp {
+ 0% {
+ opacity: 1;
+ -moz-transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -moz-transform: translateY(-20px);
+ }
+}
+@-o-keyframes fadeOutUp {
+ 0% {
+ opacity: 1;
+ -o-transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -o-transform: translateY(-20px);
+ }
+}
+@keyframes fadeOutUp {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateY(-20px);
+ }
+}
+
+.fadeOutUp {
+ -webkit-animation-name: fadeOutUp;
+ -moz-animation-name: fadeOutUp;
+ -o-animation-name: fadeOutUp;
+ animation-name: fadeOutUp;
+}
+@-webkit-keyframes fadeOutDown {
+ 0% {
+ opacity: 1;
+ -webkit-transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateY(20px);
+ }
+}
+
+@-moz-keyframes fadeOutDown {
+ 0% {
+ opacity: 1;
+ -moz-transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -moz-transform: translateY(20px);
+ }
+}
+
+@-o-keyframes fadeOutDown {
+ 0% {
+ opacity: 1;
+ -o-transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -o-transform: translateY(20px);
+ }
+}
+
+@keyframes fadeOutDown {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+}
+
+.fadeOutDown {
+ -webkit-animation-name: fadeOutDown;
+ -moz-animation-name: fadeOutDown;
+ -o-animation-name: fadeOutDown;
+ animation-name: fadeOutDown;
+}
+@-webkit-keyframes fadeOutLeft {
+ 0% {
+ opacity: 1;
+ -webkit-transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateX(-20px);
+ }
+}
+
+@-moz-keyframes fadeOutLeft {
+ 0% {
+ opacity: 1;
+ -moz-transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -moz-transform: translateX(-20px);
+ }
+}
+
+@-o-keyframes fadeOutLeft {
+ 0% {
+ opacity: 1;
+ -o-transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -o-transform: translateX(-20px);
+ }
+}
+
+@keyframes fadeOutLeft {
+ 0% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+}
+
+.fadeOutLeft {
+ -webkit-animation-name: fadeOutLeft;
+ -moz-animation-name: fadeOutLeft;
+ -o-animation-name: fadeOutLeft;
+ animation-name: fadeOutLeft;
+}
+@-webkit-keyframes fadeOutRight {
+ 0% {
+ opacity: 1;
+ -webkit-transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateX(20px);
+ }
+}
+
+@-moz-keyframes fadeOutRight {
+ 0% {
+ opacity: 1;
+ -moz-transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -moz-transform: translateX(20px);
+ }
+}
+
+@-o-keyframes fadeOutRight {
+ 0% {
+ opacity: 1;
+ -o-transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ -o-transform: translateX(20px);
+ }
+}
+
+@keyframes fadeOutRight {
+ 0% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+}
+
+.fadeOutRight {
+ -webkit-animation-name: fadeOutRight;
+ -moz-animation-name: fadeOutRight;
+ -o-animation-name: fadeOutRight;
+ animation-name: fadeOutRight;
+}
diff --git a/common/static/css/vendor/simplePagination/LICENSE.txt b/common/static/css/vendor/simplePagination/LICENSE.txt
new file mode 100644
index 000000000000..dca02848068c
--- /dev/null
+++ b/common/static/css/vendor/simplePagination/LICENSE.txt
@@ -0,0 +1,21 @@
+Copyright 2012, Flavius Matis
+http://flaviusmatis.github.com/
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/common/static/css/vendor/simplePagination/simplePagination.css b/common/static/css/vendor/simplePagination/simplePagination.css
new file mode 100644
index 000000000000..2b897a0c9e9d
--- /dev/null
+++ b/common/static/css/vendor/simplePagination/simplePagination.css
@@ -0,0 +1,187 @@
+/**
+* CSS themes for simplePagination.js
+* Author: Flavius Matis - http://flaviusmatis.github.com/
+* URL: https://github.com/flaviusmatis/simplePagination.js
+*/
+
+ul.simple-pagination {
+ list-style: none;
+}
+
+.simple-pagination {
+ display: block;
+ overflow: hidden;
+ padding: 0 5px 5px 0;
+ margin: 0;
+}
+
+.simple-pagination ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.simple-pagination li {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ float: left;
+}
+
+/*------------------------------------*\
+ Compact Theme Styles
+\*------------------------------------*/
+
+.compact-theme a, .compact-theme span {
+ float: left;
+ color: #333;
+ font-size:14px;
+ line-height:24px;
+ font-weight: normal;
+ text-align: center;
+ border: 1px solid #AAA;
+ border-right: none;
+ min-width: 14px;
+ padding: 0 7px;
+ box-shadow: 2px 2px 2px rgba(0,0,0,0.2);
+ background: #efefef; /* Old browsers */
+ background: -moz-linear-gradient(top, #ffffff 0%, #efefef 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#ffffff), color-stop(100%,#efefef)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, #ffffff 0%,#efefef 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, #ffffff 0%,#efefef 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, #ffffff 0%,#efefef 100%); /* IE10+ */
+ background: linear-gradient(top, #ffffff 0%,#efefef 100%); /* W3C */
+}
+
+.compact-theme a:hover {
+ text-decoration: none;
+ background: #efefef; /* Old browsers */
+ background: -moz-linear-gradient(top, #efefef 0%, #bbbbbb 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#efefef), color-stop(100%,#bbbbbb)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, #efefef 0%,#bbbbbb 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, #efefef 0%,#bbbbbb 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, #efefef 0%,#bbbbbb 100%); /* IE10+ */
+ background: linear-gradient(top, #efefef 0%,#bbbbbb 100%); /* W3C */
+}
+
+.compact-theme .prev {
+ border-radius: 3px 0 0 3px;
+}
+
+.compact-theme .next {
+ border-right: 1px solid #AAA;
+ border-radius: 0 3px 3px 0;
+}
+
+.compact-theme .current {
+ background: #bbbbbb; /* Old browsers */
+ background: -moz-linear-gradient(top, #bbbbbb 0%, #efefef 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#bbbbbb), color-stop(100%,#efefef)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, #bbbbbb 0%,#efefef 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, #bbbbbb 0%,#efefef 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, #bbbbbb 0%,#efefef 100%); /* IE10+ */
+ background: linear-gradient(top, #bbbbbb 0%,#efefef 100%); /* W3C */
+ cursor: default;
+}
+
+.compact-theme .ellipse {
+ background: #EAEAEA;
+ padding: 0 10px;
+ cursor: default;
+}
+
+/*------------------------------------*\
+ Light Theme Styles
+\*------------------------------------*/
+
+.light-theme a, .light-theme span {
+ float: left;
+ color: #666;
+ font-size:14px;
+ line-height:24px;
+ font-weight: normal;
+ text-align: center;
+ border: 1px solid #BBB;
+ min-width: 14px;
+ padding: 0 7px;
+ margin: 0 5px 0 0;
+ border-radius: 3px;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+ background: #efefef; /* Old browsers */
+ background: -moz-linear-gradient(top, #ffffff 0%, #efefef 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#ffffff), color-stop(100%,#efefef)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, #ffffff 0%,#efefef 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, #ffffff 0%,#efefef 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, #ffffff 0%,#efefef 100%); /* IE10+ */
+ background: linear-gradient(top, #ffffff 0%,#efefef 100%); /* W3C */
+}
+
+.light-theme a:hover {
+ text-decoration: none;
+ background: #FCFCFC;
+}
+
+.light-theme .current {
+ background: #666;
+ color: #FFF;
+ border-color: #444;
+ box-shadow: 0 1px 0 rgba(255,255,255,1), 0 0 2px rgba(0, 0, 0, 0.3) inset;
+ cursor: default;
+}
+
+.light-theme .ellipse {
+ background: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ font-weight: bold;
+ cursor: default;
+}
+
+/*------------------------------------*\
+ Dark Theme Styles
+\*------------------------------------*/
+
+.dark-theme a, .dark-theme span {
+ float: left;
+ color: #CCC;
+ font-size:14px;
+ line-height:24px;
+ font-weight: normal;
+ text-align: center;
+ border: 1px solid #222;
+ min-width: 14px;
+ padding: 0 7px;
+ margin: 0 5px 0 0;
+ border-radius: 3px;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.2);
+ background: #555; /* Old browsers */
+ background: -moz-linear-gradient(top, #555 0%, #333 100%); /* FF3.6+ */
+ background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#555), color-stop(100%,#333)); /* Chrome,Safari4+ */
+ background: -webkit-linear-gradient(top, #555 0%,#333 100%); /* Chrome10+,Safari5.1+ */
+ background: -o-linear-gradient(top, #555 0%,#333 100%); /* Opera11.10+ */
+ background: -ms-linear-gradient(top, #555 0%,#333 100%); /* IE10+ */
+ background: linear-gradient(top, #555 0%,#333 100%); /* W3C */
+}
+
+.dark-theme a:hover {
+ text-decoration: none;
+ background: #444;
+}
+
+.dark-theme .current {
+ background: #222;
+ color: #FFF;
+ border-color: #000;
+ box-shadow: 0 1px 0 rgba(255,255,255,0.2), 0 0 1px 1px rgba(0, 0, 0, 0.1) inset;
+ cursor: default;
+}
+
+.dark-theme .ellipse {
+ background: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ font-weight: bold;
+ cursor: default;
+}
\ No newline at end of file
diff --git a/common/static/images/search-icon.svg b/common/static/images/search-icon.svg
new file mode 100644
index 000000000000..3490288a4cc7
--- /dev/null
+++ b/common/static/images/search-icon.svg
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/common/static/js/search.js b/common/static/js/search.js
new file mode 100644
index 000000000000..d371ee26392a
--- /dev/null
+++ b/common/static/js/search.js
@@ -0,0 +1,227 @@
+function getParameters(){
+ var paramstr = window.location.search.substr(1);
+ var args = paramstr.split("&");
+ var params = {};
+
+ for (var i=0; i < args.length; i++){
+ var temparray = args[i].split("=");
+ params[temparray[0]] = temparray[1];
+ }
+
+ return params;
+}
+
+function getSearchAction(){
+ var urlSplit = document.URL.split("/");
+ var courseIndex = urlSplit.indexOf("courses");
+ var searchAction = urlSplit.slice(courseIndex, courseIndex+4);
+ searchAction.push("search");
+ return searchAction.join("/");
+}
+
+function constructSearchBox(value){
+ var searchWrapper = document.createElement("div");
+ searchWrapper.className = "animated fadeInRight search-wrapper";
+ searchWrapper.id = "search-wrapper";
+
+ var searchForm = document.createElement("form");
+ searchForm.className = "auto-submit";
+ searchForm.id = "query-box";
+ searchForm.action = "/"+getSearchAction();
+ searchForm.method = "get";
+
+ var searchBoxWrapper = document.createElement("div");
+ searchBoxWrapper.className = "searchbox-wrapper";
+
+ var searchBox = document.createElement("input");
+ searchBox.id = "searchbox";
+ searchBox.type = "text";
+ searchBox.className = "searchbox parameter";
+ searchBox.name = "s";
+ searchBox.value = value;
+
+ searchBoxWrapper.appendChild(searchBox);
+ searchForm.appendChild(searchBoxWrapper);
+ searchWrapper.appendChild(searchForm);
+
+ return searchWrapper;
+}
+
+function replaceWithSearch(){
+ $(this).addClass("animated fadeOut");
+ var searchWrapper = constructSearchBox("");
+ var width = $("div.search-icon").width();
+ var height = $("div.search-icon").height();
+ $(this).on('webkitAnimationEnd oanimationend oAnimationEnd msAnimationEnd animationend',
+ function (e){
+ $(this).parent().replaceWith(searchWrapper);
+ $("#searchbox").css("width", width);
+ $("#searchbox").css("height", height);
+ if (document.URL.indexOf("search?s=") == -1){
+ document.getElementById("searchbox").focus();
+ }
+ });
+}
+
+function updateOldSearch(){
+ var params = getParameters();
+ var newBox = constructSearchBox(old_query);
+ var courseTab = $("a.search-bar").get(0);
+ if (typeof courseTab != 'undefined'){
+ courseTab.parentNode.replaceChild(newBox, courseTab);
+ }
+}
+
+function paginate(element){
+ var currentResults = parseInt($("._currentFilter span.count").text(), 10);
+ $(element).pagination({
+ items : currentResults,
+ itemsOnPage : 10,
+ currentPage : page,
+ displayedPages : 3,
+ edges : 2,
+ cssStyle : "light-theme",
+ onPageClick : function(pageNumber, event){
+ console.log("Hooray!");
+ event.preventDefault();
+ replaceCurrentContent(current_filter, pageNumber);
+ }
+ });
+}
+
+function moveFilterClasses(){
+ /**
+ * Keeps all of the classes related to filters on the proper DOM elements on update
+ *
+ * We are indicating current filter by assigning a class to the element in the DOM.
+ * This function makes sure that these classes stay on the correct elements.
+ */
+
+ $("._currentFilter").removeClass("_currentFilter");
+ if (document.location.href.match(/filter=\w+/)){
+ var currentFilter = document.location.href.match(/filter=(\w+)/)[1];
+ var newFilter = $("#"+currentFilter);
+ newFilter.addClass("_currentFilter");
+ }
+ else {
+ $("#all").addClass("_currentFilter");
+ }
+}
+
+function getSearchResults(resultsObject, filter, current_page){
+ /**
+ * Returns relevant portion of search results
+ * Assume that results Object will just be a parsed version of the
+ * search_results variable passed in from the template.
+ */
+
+ return resultsObject[filter].results[current_page];
+}
+
+function renderSearchResult(searchResult){
+ /**
+ * Renders the given search result into a contained section element
+ */
+
+ var resultTitle = document.createElement("h1");
+ resultTitle.className = "result-title";
+ resultTitle.innerHTML = searchResult.data.display_name;
+
+ var category = document.createElement("span");
+ category.className = searchResult.category + "-image";
+
+ var resultHeader = document.createElement("div");
+ resultHeader.className = "result-header";
+ resultHeader.appendChild(category);
+ resultHeader.appendChild(resultTitle);
+
+ var thumbnail = document.createElement("img");
+ thumbnail.className = "thumbnail";
+ if (searchResult.thumbnail !== undefined){
+ thumbnail.src = searchResult.thumbnail;
+ thumbnail.alt = searchResult.data.display_name;
+ }
+
+ var resultThumbnail = document.createElement("div");
+ resultThumbnail.className = "result-thumbnail";
+ resultThumbnail.appendChild(thumbnail);
+
+ var thumbnailWrapper = document.createElement("div");
+ thumbnailWrapper.className = "thumbnail-wrapper";
+ thumbnailWrapper.appendChild(resultThumbnail);
+
+ var snippets = document.createElement("div");
+ snippets.className = "snippet";
+ snippets.innerHTML = searchResult.snippets;
+
+ var resultSnippets = document.createElement("div");
+ resultSnippets.className = "result-snippets";
+ resultSnippets.appendChild(snippets);
+
+ var resultBody = document.createElement("div");
+ resultBody.className = "result-body";
+ resultBody.appendChild(thumbnailWrapper);
+ resultBody.appendChild(resultSnippets);
+
+ var resultContainer = document.createElement("div");
+ resultContainer.className = "result-container";
+ resultContainer.appendChild(resultHeader);
+ resultContainer.appendChild(resultBody);
+
+ var link = document.createElement('a');
+ link.href = searchResult.url;
+ link.appendChild(resultContainer);
+
+ var section = document.createElement("section");
+ section.appendChild(link);
+
+ return section;
+
+}
+
+function replaceCurrentContent(filter, current_page){
+ /**
+ * Replaces the current content of search contents
+ */
+
+ var searchResults = jQuery.parseJSON(search_results);
+ var relevantPage = getSearchResults(searchResults, filter, current_page);
+ if (relevantPage !== undefined){
+ $("div.search-container section").remove();
+ for (var i=0; i 0) {
+ paginate($("p.pagination-stub").eq(0));
+ }
+
+ $("ul.filter-menu li").bind("click", changeFilter);
+});
+
diff --git a/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/.gitignore b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/.gitignore
new file mode 100644
index 000000000000..1c2d52b6c9c3
--- /dev/null
+++ b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/.gitignore
@@ -0,0 +1 @@
+.idea/*
diff --git a/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/LICENSE.txt b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/LICENSE.txt
new file mode 100644
index 000000000000..dca02848068c
--- /dev/null
+++ b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/LICENSE.txt
@@ -0,0 +1,21 @@
+Copyright 2012, Flavius Matis
+http://flaviusmatis.github.com/
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/README.md b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/README.md
new file mode 100644
index 000000000000..25209a37cd72
--- /dev/null
+++ b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/README.md
@@ -0,0 +1,3 @@
+A simple jQuery pagination plugin and 3 CSS themes.
+
+[Read Full Documentation](http://flaviusmatis.github.com/simplePagination.js/)
\ No newline at end of file
diff --git a/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/jquery.simplePagination.js b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/jquery.simplePagination.js
new file mode 100644
index 000000000000..937179c2468a
--- /dev/null
+++ b/common/static/js/vendor/flaviusmatis-simplePagination.js-7cefeb7/jquery.simplePagination.js
@@ -0,0 +1,236 @@
+/**
+* simplePagination.js v1.6
+* A simple jQuery pagination plugin.
+* http://flaviusmatis.github.com/simplePagination.js/
+*
+* Copyright 2012, Flavius Matis
+* Released under the MIT license.
+* http://flaviusmatis.github.com/license.html
+*/
+
+(function($){
+
+ var methods = {
+ init: function(options) {
+ var o = $.extend({
+ items: 1,
+ itemsOnPage: 1,
+ pages: 0,
+ displayedPages: 5,
+ edges: 2,
+ currentPage: 1,
+ hrefTextPrefix: '#page-',
+ hrefTextSuffix: '',
+ prevText: 'Prev',
+ nextText: 'Next',
+ ellipseText: '…',
+ cssStyle: 'light-theme',
+ selectOnClick: true,
+ onPageClick: function(pageNumber, event) {
+ // Callback triggered when a page is clicked
+ // Page number is given as an optional parameter
+ },
+ onInit: function() {
+ // Callback triggered immediately after initialization
+ }
+ }, options || {});
+
+ var self = this;
+
+ o.pages = o.pages ? o.pages : Math.ceil(o.items / o.itemsOnPage) ? Math.ceil(o.items / o.itemsOnPage) : 1;
+ o.currentPage = o.currentPage - 1;
+ o.halfDisplayed = o.displayedPages / 2;
+
+ this.each(function() {
+ self.addClass(o.cssStyle + ' simple-pagination').data('pagination', o);
+ methods._draw.call(self);
+ });
+
+ o.onInit();
+
+ return this;
+ },
+
+ selectPage: function(page) {
+ methods._selectPage.call(this, page - 1);
+ return this;
+ },
+
+ prevPage: function() {
+ var o = this.data('pagination');
+ if (o.currentPage > 0) {
+ methods._selectPage.call(this, o.currentPage - 1);
+ }
+ return this;
+ },
+
+ nextPage: function() {
+ var o = this.data('pagination');
+ if (o.currentPage < o.pages - 1) {
+ methods._selectPage.call(this, o.currentPage + 1);
+ }
+ return this;
+ },
+
+ getPagesCount: function() {
+ return this.data('pagination').pages;
+ },
+
+ getCurrentPage: function () {
+ return this.data('pagination').currentPage + 1;
+ },
+
+ destroy: function(){
+ this.empty();
+ return this;
+ },
+
+ redraw: function(){
+ methods._draw.call(this);
+ return this;
+ },
+
+ disable: function(){
+ var o = this.data('pagination');
+ o.disabled = true;
+ this.data('pagination', o);
+ methods._draw.call(this);
+ return this;
+ },
+
+ enable: function(){
+ var o = this.data('pagination');
+ o.disabled = false;
+ this.data('pagination', o);
+ methods._draw.call(this);
+ return this;
+ },
+
+ updateItems: function (newItems) {
+ var o = this.data('pagination');
+ o.items = newItems;
+ o.pages = Math.ceil(o.items / o.itemsOnPage) ? Math.ceil(o.items / o.itemsOnPage) : 1;
+ this.data('pagination', o);
+ methods._draw.call(this);
+ },
+
+ _draw: function() {
+ var o = this.data('pagination'),
+ interval = methods._getInterval(o),
+ i;
+
+ methods.destroy.call(this);
+
+ var $panel = this.prop("tagName") === "UL" ? this : $('').appendTo(this);
+
+ // Generate Prev link
+ if (o.prevText) {
+ methods._appendItem.call(this, o.currentPage - 1, {text: o.prevText, classes: 'prev'});
+ }
+
+ // Generate start edges
+ if (interval.start > 0 && o.edges > 0) {
+ var end = Math.min(o.edges, interval.start);
+ for (i = 0; i < end; i++) {
+ methods._appendItem.call(this, i);
+ }
+ if (o.edges < interval.start && (interval.start - o.edges != 1)) {
+ $panel.append('' + o.ellipseText + ' ');
+ } else if (interval.start - o.edges == 1) {
+ methods._appendItem.call(this, o.edges);
+ }
+ }
+
+ // Generate interval links
+ for (i = interval.start; i < interval.end; i++) {
+ methods._appendItem.call(this, i);
+ }
+
+ // Generate end edges
+ if (interval.end < o.pages && o.edges > 0) {
+ if (o.pages - o.edges > interval.end && (o.pages - o.edges - interval.end != 1)) {
+ $panel.append('' + o.ellipseText + ' ');
+ } else if (o.pages - o.edges - interval.end == 1) {
+ methods._appendItem.call(this, interval.end++);
+ }
+ var begin = Math.max(o.pages - o.edges, interval.end);
+ for (i = begin; i < o.pages; i++) {
+ methods._appendItem.call(this, i);
+ }
+ }
+
+ // Generate Next link
+ if (o.nextText) {
+ methods._appendItem.call(this, o.currentPage + 1, {text: o.nextText, classes: 'next'});
+ }
+ },
+
+ _getInterval: function(o) {
+ return {
+ start: Math.ceil(o.currentPage > o.halfDisplayed ? Math.max(Math.min(o.currentPage - o.halfDisplayed, (o.pages - o.displayedPages)), 0) : 0),
+ end: Math.ceil(o.currentPage > o.halfDisplayed ? Math.min(o.currentPage + o.halfDisplayed, o.pages) : Math.min(o.displayedPages, o.pages))
+ };
+ },
+
+ _appendItem: function(pageIndex, opts) {
+ var self = this, options, $link, o = self.data('pagination'), $linkWrapper = $(' '), $ul = self.find('ul');
+
+ pageIndex = pageIndex < 0 ? 0 : (pageIndex < o.pages ? pageIndex : o.pages - 1);
+
+ options = $.extend({
+ text: pageIndex + 1,
+ classes: ''
+ }, opts || {});
+
+ if (pageIndex == o.currentPage || o.disabled) {
+ if (o.disabled) {
+ $linkWrapper.addClass('disabled');
+ } else {
+ $linkWrapper.addClass('active');
+ }
+ $link = $('' + (options.text) + ' ');
+ } else {
+ $link = $('' + (options.text) + ' ');
+ $link.click(function(event){
+ return methods._selectPage.call(self, pageIndex, event);
+ });
+ }
+
+ if (options.classes) {
+ $link.addClass(options.classes);
+ }
+
+ $linkWrapper.append($link);
+
+ if ($ul.length) {
+ $ul.append($linkWrapper);
+ } else {
+ self.append($linkWrapper);
+ }
+ },
+
+ _selectPage: function(pageIndex, event) {
+ var o = this.data('pagination');
+ o.currentPage = pageIndex;
+ if (o.selectOnClick) {
+ methods._draw.call(this);
+ }
+ return o.onPageClick(pageIndex + 1, event);
+ }
+
+ };
+
+ $.fn.pagination = function(method) {
+
+ // Method calling logic
+ if (methods[method] && method.charAt(0) != '_') {
+ return methods[method].apply(this, Array.prototype.slice.call(arguments, 1));
+ } else if (typeof method === 'object' || !method) {
+ return methods.init.apply(this, arguments);
+ } else {
+ $.error('Method ' + method + ' does not exist on jQuery.pagination');
+ }
+
+ };
+
+})(jQuery);
diff --git a/common/static/js/vendor/jquery.paginate.js b/common/static/js/vendor/jquery.paginate.js
new file mode 100644
index 000000000000..050f39d1f06f
--- /dev/null
+++ b/common/static/js/vendor/jquery.paginate.js
@@ -0,0 +1,249 @@
+(function($) {
+ $.fn.paginate = function(options) {
+ var opts = $.extend({}, $.fn.paginate.defaults, options);
+ return this.each(function() {
+ $this = $(this);
+ var o = $.meta ? $.extend({}, opts, $this.data()) : opts;
+ var selectedpage = o.start;
+ $.fn.draw(o,$this,selectedpage);
+ });
+ };
+ var outsidewidth_tmp = 0;
+ var insidewidth = 0;
+ var bName = navigator.appName;
+ var bVer = navigator.appVersion;
+ if(bVer.indexOf('MSIE 7.0') > 0)
+ var ver = "ie7";
+ $.fn.paginate.defaults = {
+ count : 5,
+ start : 12,
+ display : 5,
+ border : true,
+ border_color : '#fff',
+ text_color : '#8cc59d',
+ background_color : 'black',
+ border_hover_color : '#fff',
+ text_hover_color : '#fff',
+ background_hover_color : '#fff',
+ rotate : true,
+ images : true,
+ mouse : 'slide',
+ onChange : function(){return false;}
+ };
+ $.fn.draw = function(o,obj,selectedpage){
+ if(o.display > o.count)
+ o.display = o.count;
+ $this.empty();
+ if(o.images){
+ var spreviousclass = 'jPag-sprevious-img';
+ var previousclass = 'jPag-previous-img';
+ var snextclass = 'jPag-snext-img';
+ var nextclass = 'jPag-next-img';
+ }
+ else{
+ var spreviousclass = 'jPag-sprevious';
+ var previousclass = 'jPag-previous';
+ var snextclass = 'jPag-snext';
+ var nextclass = 'jPag-next';
+ }
+ var _first = $(document.createElement('a')).addClass('jPag-first').html('First');
+
+ if(o.rotate){
+ if(o.images) var _rotleft = $(document.createElement('span')).addClass(spreviousclass);
+ else var _rotleft = $(document.createElement('span')).addClass(spreviousclass).html('«');
+ }
+
+ var _divwrapleft = $(document.createElement('div')).addClass('jPag-control-back');
+ _divwrapleft.append(_first).append(_rotleft);
+
+ var _ulwrapdiv = $(document.createElement('div')).css('overflow','hidden');
+ var _ul = $(document.createElement('ul')).addClass('jPag-pages')
+ var c = (o.display - 1) / 2;
+ var first = selectedpage - c;
+ var selobj;
+ for(var i = 0; i < o.count; i++){
+ var val = i+1;
+ if(val == selectedpage){
+ var _obj = $(document.createElement('li')).html(''+val+' ');
+ selobj = _obj;
+ _ul.append(_obj);
+ }
+ else{
+ var _obj = $(document.createElement('li')).html(''+ val +' ');
+ _ul.append(_obj);
+ }
+ }
+ _ulwrapdiv.append(_ul);
+
+ if(o.rotate){
+ if(o.images) var _rotright = $(document.createElement('span')).addClass(snextclass);
+ else var _rotright = $(document.createElement('span')).addClass(snextclass).html('»');
+ }
+
+ var _last = $(document.createElement('a')).addClass('jPag-last').html('Last');
+ var _divwrapright = $(document.createElement('div')).addClass('jPag-control-front');
+ _divwrapright.append(_rotright).append(_last);
+
+ //append all:
+ $this.addClass('jPaginate').append(_divwrapleft).append(_ulwrapdiv).append(_divwrapright);
+
+ if(!o.border){
+ if(o.background_color == 'none') var a_css = {'color':o.text_color};
+ else var a_css = {'color':o.text_color,'background-color':o.background_color};
+ if(o.background_hover_color == 'none') var hover_css = {'color':o.text_hover_color};
+ else var hover_css = {'color':o.text_hover_color,'background-color':o.background_hover_color};
+ }
+ else{
+ if(o.background_color == 'none') var a_css = {'color':o.text_color,'border':'1px solid '+o.border_color};
+ else var a_css = {'color':o.text_color,'background-color':o.background_color,'border':'1px solid '+o.border_color};
+ if(o.background_hover_color == 'none') var hover_css = {'color':o.text_hover_color,'border':'1px solid '+o.border_hover_color};
+ else var hover_css = {'color':o.text_hover_color,'background-color':o.background_hover_color,'border':'1px solid '+o.border_hover_color};
+ }
+
+ $.fn.applystyle(o,$this,a_css,hover_css,_first,_ul,_ulwrapdiv,_divwrapright);
+ //calculate width of the ones displayed:
+ var outsidewidth = outsidewidth_tmp - _first.parent().width() -3;
+ if(ver == 'ie7'){
+ _ulwrapdiv.css('width',outsidewidth+72+'px');
+ _divwrapright.css('left',outsidewidth_tmp+6+72+'px');
+ }
+ else{
+ _ulwrapdiv.css('width',outsidewidth+'px');
+ _divwrapright.css('left',outsidewidth_tmp+6+'px');
+ }
+
+ if(o.rotate){
+ _rotright.hover(
+ function() {
+ thumbs_scroll_interval = setInterval(
+ function() {
+ var left = _ulwrapdiv.scrollLeft() + 1;
+ _ulwrapdiv.scrollLeft(left);
+ },
+ 20
+ );
+ },
+ function() {
+ clearInterval(thumbs_scroll_interval);
+ }
+ );
+ _rotleft.hover(
+ function() {
+ thumbs_scroll_interval = setInterval(
+ function() {
+ var left = _ulwrapdiv.scrollLeft() - 1;
+ _ulwrapdiv.scrollLeft(left);
+ },
+ 20
+ );
+ },
+ function() {
+ clearInterval(thumbs_scroll_interval);
+ }
+ );
+ if(o.mouse == 'press'){
+ _rotright.mousedown(
+ function() {
+ thumbs_mouse_interval = setInterval(
+ function() {
+ var left = _ulwrapdiv.scrollLeft() + 5;
+ _ulwrapdiv.scrollLeft(left);
+ },
+ 20
+ );
+ }
+ ).mouseup(
+ function() {
+ clearInterval(thumbs_mouse_interval);
+ }
+ );
+ _rotleft.mousedown(
+ function() {
+ thumbs_mouse_interval = setInterval(
+ function() {
+ var left = _ulwrapdiv.scrollLeft() - 5;
+ _ulwrapdiv.scrollLeft(left);
+ },
+ 20
+ );
+ }
+ ).mouseup(
+ function() {
+ clearInterval(thumbs_mouse_interval);
+ }
+ );
+ }
+ else{
+ _rotleft.click(function(e){
+ var width = outsidewidth - 10;
+ var left = _ulwrapdiv.scrollLeft() - width;
+ _ulwrapdiv.animate({scrollLeft: left +'px'});
+ });
+
+ _rotright.click(function(e){
+ var width = outsidewidth - 10;
+ var left = _ulwrapdiv.scrollLeft() + width;
+ _ulwrapdiv.animate({scrollLeft: left +'px'});
+ });
+ }
+ }
+
+ //first and last:
+ _first.click(function(e){
+ _ulwrapdiv.animate({scrollLeft: '0px'});
+ _ulwrapdiv.find('li').eq(0).click();
+ });
+ _last.click(function(e){
+ _ulwrapdiv.animate({scrollLeft: insidewidth +'px'});
+ _ulwrapdiv.find('li').eq(o.count - 1).click();
+ });
+
+ //click a page
+ _ulwrapdiv.find('li').click(function(e){
+ selobj.html(''+selobj.find('.jPag-current').html()+' ');
+ var currval = $(this).find('a').html();
+ $(this).html(''+currval+' ');
+ selobj = $(this);
+ $.fn.applystyle(o,$(this).parent().parent().parent(),a_css,hover_css,_first,_ul,_ulwrapdiv,_divwrapright);
+ var left = (this.offsetLeft) / 2;
+ var left2 = _ulwrapdiv.scrollLeft() + left;
+ var tmp = left - (outsidewidth / 2);
+ if(ver == 'ie7')
+ _ulwrapdiv.animate({scrollLeft: left + tmp - _first.parent().width() + 52 + 'px'});
+ else
+ _ulwrapdiv.animate({scrollLeft: left + tmp - _first.parent().width() + 'px'});
+ o.onChange(currval);
+ });
+
+ var last = _ulwrapdiv.find('li').eq(o.start-1);
+ last.attr('id','tmp');
+ var left = document.getElementById('tmp').offsetLeft / 2;
+ last.removeAttr('id');
+ var tmp = left - (outsidewidth / 2);
+ if(ver == 'ie7') _ulwrapdiv.animate({scrollLeft: left + tmp - _first.parent().width() + 52 + 'px'});
+ else _ulwrapdiv.animate({scrollLeft: left + tmp - _first.parent().width() + 'px'});
+ }
+
+ $.fn.applystyle = function(o,obj,a_css,hover_css,_first,_ul,_ulwrapdiv,_divwrapright){
+ obj.find('a').css(a_css);
+ obj.find('span.jPag-current').css(hover_css);
+ obj.find('a').hover(
+ function(){
+ $(this).css(hover_css);
+ },
+ function(){
+ $(this).css(a_css);
+ }
+ );
+ obj.css('padding-left',_first.parent().width() + 5 +'px');
+ insidewidth = 0;
+
+ obj.find('li').each(function(i,n){
+ if(i == (o.display-1)){
+ outsidewidth_tmp = this.offsetLeft + this.offsetWidth ;
+ }
+ insidewidth += this.offsetWidth;
+ })
+ _ul.css('width',insidewidth+'px');
+ }
+})(jQuery);
\ No newline at end of file
diff --git a/common/templates/search_templates/results.html b/common/templates/search_templates/results.html
new file mode 100644
index 000000000000..62878e3d2ffa
--- /dev/null
+++ b/common/templates/search_templates/results.html
@@ -0,0 +1,96 @@
+<%inherit file="/main.html" />
+<%block name="bodyclass">${course.css_class}%block>
+<%namespace name='static' file='/static_content.html'/>
+<%block name="headextra">
+ <%static:css group='course'/>
+ <%namespace name='static' file='/static_content.html'/>
+
+
+
+%block>
+<%include file="/courseware/course_navigation.html" args="active_page='search'" />
+
+
+
+
+ % if search_results[current_filter]["total"] > 0:
+
+
+ Showing ${search_results[current_filter]["total"]} results in courseware for ${course_id.split("/")[1]}
+
+
+ % for result in search_results[current_filter]["results"][page]:
+
+ % endfor
+
+ % else:
+
No results found, please try again
+ % endif
+
+
+
+
+<%!
+ import json
+ import re
+
+ def to_string(context):
+ return re.escape(json.dumps(context))
+%>
\ No newline at end of file
diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py
index 6579e631d6de..adb7bb6ce8ed 100644
--- a/lms/djangoapps/courseware/tabs.py
+++ b/lms/djangoapps/courseware/tabs.py
@@ -101,8 +101,10 @@ def _discussion(tab, user, course, active_page, request):
This tab format only supports the new Berkeley discussion forums.
"""
if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
- link = reverse('django_comment_client.forum.views.forum_form_discussion',
- args=[course.id])
+ link = reverse(
+ 'django_comment_client.forum.views.forum_form_discussion',
+ args=[course.id]
+ )
return [CourseTab(tab['name'], link, active_page == 'discussion')]
return []
@@ -137,7 +139,7 @@ def _textbooks(tab, user, course, active_page, request):
return []
-def _pdf_textbooks(tab, user, course, active_page, request):
+def _pdf_textbooks(tab, user, course, active_page):
"""
Generates one tab per textbook. Only displays if user is authenticated.
"""
@@ -149,7 +151,7 @@ def _pdf_textbooks(tab, user, course, active_page, request):
return []
-def _html_textbooks(tab, user, course, active_page, request):
+def _html_textbooks(tab, user, course, active_page):
"""
Generates one tab per textbook. Only displays if user is authenticated.
"""
@@ -167,7 +169,7 @@ def _staff_grading(tab, user, course, active_page, request):
tab_name = "Staff grading"
- notifications = open_ended_notifications.staff_grading_notifications(course, user)
+ notifications = open_ended_notifications.staff_grading_notifications(course, user)
pending_grading = notifications['pending_grading']
img_path = notifications['img_path']
@@ -201,7 +203,7 @@ def _combined_open_ended_grading(tab, user, course, active_page, request):
link = reverse('open_ended_notifications', args=[course.id])
tab_name = "Open Ended Panel"
- notifications = open_ended_notifications.combined_notifications(course, user)
+ notifications = open_ended_notifications.combined_notifications(course, user)
pending_grading = notifications['pending_grading']
img_path = notifications['img_path']
@@ -259,7 +261,7 @@ def null_validator(d):
'open_ended': TabImpl(null_validator, _combined_open_ended_grading),
'notes': TabImpl(null_validator, _notes_tab),
'syllabus': TabImpl(null_validator, _syllabus)
- }
+}
### External interface below this.
@@ -325,7 +327,7 @@ def get_course_tabs(user, course, active_page, request):
# via feature flags, and things like 'textbook' which might generate
# multiple tabs.
gen = VALID_TAB_TYPES[tab['type']].generator
- tabs.extend(gen(tab, user, course, active_page, request))
+ tabs.extend(gen(tab, user, course, active_page))
# Instructor tab is special--automatically added if user is staff for the course
if has_access(user, course, 'staff'):
@@ -336,6 +338,12 @@ def get_course_tabs(user, course, active_page, request):
return tabs
+def check_search_enable(course):
+ """
+ Returns a boolean representing whether or not search is enabled for the given course
+ """
+ return True
+
def get_discussion_link(course):
"""
Return the URL for the discussion tab for the given `course`.
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index c5489d3b30cb..8ace76d96dc6 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -297,7 +297,7 @@ def index(request, course_id, chapter=None, section=None,
- HTTPresponse
"""
user = User.objects.prefetch_related("groups").get(id=request.user.id)
- request.user = user # keep just one instance of User
+ request.user = user # keep just one instance of User
course = get_course_with_access(user, course_id, 'load', depth=2)
staff_access = has_access(user, course, 'staff')
registered = registered_for_course(course, user)
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 96b304294d3d..2a5b85092a43 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -179,6 +179,9 @@
# Enable flow for payments for course registration (DIFFERENT from verified student flow)
'ENABLE_PAID_COURSE_REGISTRATION': False,
+
+ # Toggle search availability
+ 'COURSE_SEARCH': False,
}
# Used for A/B testing
@@ -428,6 +431,16 @@
ADMINS = ()
MANAGERS = ADMINS
+# Search
+ES_DATABASE = "http://localhost:9200"
+# Tokenizer is for english here, but there are a number of alternate tokenizers for other languages
+SENTENCE_TOKENIZER = "tokenizers/punkt/english.pickle"
+# Same as SENTENCE_TOKENIZER, STEMMER is also currently for English, but allows for a detect value
+STEMMER = "english"
+# Settings file for Elastic Search with relevant analyzers and tokenizers
+current_directory = os.path.dirname(os.path.realpath(__file__))
+settings_file = os.path.join(current_directory, "es_settings.json")
+ES_SETTINGS = open(settings_file).read()
# Static content
STATIC_URL = '/static/'
ADMIN_MEDIA_PREFIX = '/static/admin/'
@@ -883,6 +896,9 @@
# Student Identity Verification
'verify_student',
+
+ # Search
+ 'search',
)
######################### MARKETING SITE ###############################
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index c596208b3f5b..065906a744c5 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -18,7 +18,6 @@
DEBUG = True
TEMPLATE_DEBUG = True
-
MITX_FEATURES['DISABLE_START_DATES'] = False
MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up
@@ -32,6 +31,7 @@
MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True
MITX_FEATURES['MULTIPLE_ENROLLMENT_ROLES'] = True
MITX_FEATURES['ENABLE_SHOPPING_CART'] = True
+MITX_FEATURES['COURSE_SEARCH'] = False
FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com"
diff --git a/lms/envs/es_settings.json b/lms/envs/es_settings.json
new file mode 100644
index 000000000000..dfae1bc2b7e2
--- /dev/null
+++ b/lms/envs/es_settings.json
@@ -0,0 +1,121 @@
+{
+ "mappings": {
+ "properties": {
+ "searchable_text": {
+ "type": "multi_field",
+ "fields": {
+ "full_words": {
+ "type": "string",
+ "store": "yes",
+ "index": "analyzed",
+ "term_vector": "with_positions_offsets",
+ "analyzer": "transcript_analyzer",
+ "boost": 2.0,
+ "similarity": "BM25"
+ },
+
+ "ngrams": {
+ "type": "string",
+ "store": "yes",
+ "index": "analyzed",
+ "term_vector": "with_positions_offsets",
+ "analyzer": "ngram_analyzer",
+ "boost": 1.0,
+ "similarity": "BM25"
+ }
+ }
+ },
+
+ "display_name": {
+ "type": "multi_field",
+ "fields": {
+
+ "depth_search": {
+ "type": "string",
+ "store": "yes",
+ "index": "analyzed",
+ "term_vector": "with_positions_offsets",
+ "analyzer": "depth_analyzer",
+ "boost": 1.0,
+ "similarity": "BM25"
+ },
+
+ "breadth_search": {
+ "type": "string",
+ "store": "yes",
+ "index": "analyzed",
+ "term_vector": "with_positions_offsets",
+ "analyzer": "breadth_analyzer",
+ "boost": 2.0,
+ "similarity": "BM25"
+ }
+ },
+
+ "id": {
+ "type": "string",
+ "store": "true"
+ },
+
+ "hash": {
+ "type": "string",
+ "store": "true"
+ },
+
+ "thumbnail": {
+ "type": "binary"
+ },
+
+ "course_id": {
+ "type": "string",
+ "store": "true"
+ }
+ }
+ }
+ },
+
+ "settings": {
+ "analysis":{
+ "analyzer": {
+
+ "transcript_analyzer": {
+ "type": "custom",
+ "tokenizer": "standard",
+ "filter": ["asciifolding", "word_delimiter", "lowercase", "custom_stemmer", "shingle",
+ "custom_phonetic"]
+ },
+
+ "ngram_analyzer": {
+ "type": "custom",
+ "tokenizer": "standard",
+ "filter": ["asciifolding", "word_delimiter", "lowercase", "custom_stemmer", "custom_phonetic"]
+ },
+
+ "depth_analyzer": {
+ "type": "custom",
+ "tokenizer": "standard",
+ "filter": ["word_delimiter", "lowercase", "shingle", "custom_phonetic"]
+ },
+
+ "breadth_analyzer": {
+ "type": "custom",
+ "tokenizer": "standard",
+ "filter": ["asciifolding", "lowercase", "custom_phonetic"]
+ }
+ },
+
+ "filter" : {
+
+ "custom_phonetic": {
+ "type": "phonetic",
+ "encoder": "doublemetaphone",
+ "replace": false
+ },
+
+ "custom_stemmer": {
+ "type": "stemmer",
+ "name": "minimal_english"
+ }
+ }
+ }
+ }
+}
diff --git a/lms/envs/test.py b/lms/envs/test.py
index f79c8c4218b7..181ef932b7c9 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -19,6 +19,10 @@
os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = 'localhost:8000-9000'
+ES_DATABASE = "http://localhost:9200"
+
+ES_SETTINGS_FILE = "../../common/djangoapps/search/settings.json"
+
# can't test start dates with this True, but on the other hand,
# can test everything else :)
MITX_FEATURES['DISABLE_START_DATES'] = True
diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html
index 8cd5368ad05f..50ed582ee258 100644
--- a/lms/templates/courseware/course_navigation.html
+++ b/lms/templates/courseware/course_navigation.html
@@ -1,4 +1,10 @@
## mako
+<%! from django.utils.translation import ugettext as _ %>
+<%namespace name='static' file='../static_content.html'/>
+
+
+
+
<%page args="active_page=None" />
<%
@@ -11,10 +17,11 @@
return "active"
return ""
%>
+
<%! from courseware.tabs import get_course_tabs %>
<%! from django.utils.translation import ugettext as _ %>
<% import waffle %>
-
+<%! from courseware.tabs import get_course_tabs, check_search_enable %>
@@ -35,12 +42,19 @@
% endfor
- <%block name="extratabs" />
+ <%block name="extratabs" />
% if masquerade is not UNDEFINED:
% if staff_access and masquerade is not None:
${_("Staff view")}
% endif
% endif
+ % if check_search_enable(course):
+
+
+
+ % endif
diff --git a/lms/urls.py b/lms/urls.py
index e624ac9f3431..436640baaa7f 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -166,6 +166,10 @@
if settings.COURSEWARE_ENABLED:
+ if settings.MITX_FEATURES.get("COURSE_SEARCH", False):
+ urlpatterns += (
+ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/search$', 'search.views.search', name="search"),
+ )
urlpatterns += (
url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/jump_to/(?P.*)$',
'courseware.views.jump_to', name="jump_to"),
diff --git a/rakelib/prereqs.rake b/rakelib/prereqs.rake
index a40e0ac5292d..659b047cb430 100644
--- a/rakelib/prereqs.rake
+++ b/rakelib/prereqs.rake
@@ -32,6 +32,8 @@ task :install_python_prereqs => "ws:migrate" do
sh('pip install -q --exists-action w -r requirements/edx/pre.txt')
sh('pip install -q --exists-action w -r requirements/edx/base.txt')
sh('pip install -q --exists-action w -r requirements/edx/post.txt')
+ sh('python -m nltk.downloader stopwords wordnet')
+ sh('python -m nltk.downloader stopwords wordnet punkt')
# requirements/private.txt is used to install our libs as
# working dirs, or for personal-use tools.
if File.file?("requirements/private.txt")
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 278c1f8aa71a..85b8765473a2 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -77,6 +77,10 @@ newrelic==1.13.1.31
# Used for documentation gathering
sphinx==1.1.3
+# Used for search
+pyfuzz==0.1.1
+guess-language==0.2
+
# Used for Internationalization and localization
Babel==1.3
transifex-client==0.9.1