Commit 56c37d0e by Clinton Blackburn Committed by Peter Fogg

Added support for course run search

- Users can now query course runs
- The preview page has been repaired

ECOM-4275
parent f750beca
...@@ -130,7 +130,10 @@ class CatalogSerializer(serializers.ModelSerializer): ...@@ -130,7 +130,10 @@ class CatalogSerializer(serializers.ModelSerializer):
class CourseRunSerializer(TimestampModelSerializer): class CourseRunSerializer(TimestampModelSerializer):
course = serializers.SlugRelatedField(read_only=True, slug_field='key') course = serializers.SlugRelatedField(read_only=True, slug_field='key')
content_language = serializers.SlugRelatedField(read_only=True, slug_field='code', source='language') content_language = serializers.SlugRelatedField(
read_only=True, slug_field='code', source='language',
help_text=_('Language in which the course is administered')
)
transcript_languages = serializers.SlugRelatedField(many=True, read_only=True, slug_field='code') transcript_languages = serializers.SlugRelatedField(many=True, read_only=True, slug_field='code')
image = ImageSerializer() image = ImageSerializer()
video = VideoSerializer() video = VideoSerializer()
......
...@@ -34,3 +34,18 @@ class CourseRunViewSetTests(APITestCase): ...@@ -34,3 +34,18 @@ class CourseRunViewSetTests(APITestCase):
response.data['results'], response.data['results'],
CourseRunSerializer(CourseRun.objects.all().order_by(Lower('key')), many=True).data CourseRunSerializer(CourseRun.objects.all().order_by(Lower('key')), many=True).data
) )
def test_list_query(self):
""" Verify the endpoint returns a filtered list of courses """
title = 'Some random course'
course_runs = CourseRunFactory.create_batch(3, title=title)
CourseRunFactory(title='non-matching name')
query = 'title:' + title
url = '{root}?q={query}'.format(root=reverse('api:v1:course_run-list'), query=query)
response = self.client.get(url)
actual_sorted = sorted(response.data['results'], key=lambda course_run: course_run['key'])
expected_sorted = sorted(
CourseRunSerializer(course_runs, many=True).data, key=lambda course_run: course_run['key']
)
self.assertListEqual(actual_sorted, expected_sorted)
...@@ -19,6 +19,7 @@ from course_discovery.apps.api.serializers import ( ...@@ -19,6 +19,7 @@ from course_discovery.apps.api.serializers import (
) )
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer
from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.constants import COURSE_ID_REGEX, COURSE_RUN_ID_REGEX from course_discovery.apps.course_metadata.constants import COURSE_ID_REGEX, COURSE_RUN_ID_REGEX
from course_discovery.apps.course_metadata.models import Course, CourseRun, Seat from course_discovery.apps.course_metadata.models import Course, CourseRun, Seat
...@@ -124,10 +125,14 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -124,10 +125,14 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
q = self.request.query_params.get('q', None) q = self.request.query_params.get('q', None)
queryset = Course.search(q) if q else super(CourseViewSet, self).get_queryset()
if q:
queryset = Course.search(q)
else:
queryset = super(CourseViewSet, self).get_queryset()
return queryset.order_by(Lower('key')) return queryset.order_by(Lower('key'))
# The boilerplate methods are required to be recognized by swagger
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" List all courses. """ List all courses.
--- ---
...@@ -154,9 +159,24 @@ class CourseRunViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -154,9 +159,24 @@ class CourseRunViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
serializer_class = CourseRunSerializer serializer_class = CourseRunSerializer
# The boilerplate methods are required to be recognized by swagger def get_queryset(self):
q = self.request.query_params.get('q', None)
if q:
return SearchQuerySetWrapper(CourseRun.search(q))
else:
return super(CourseRunViewSet, self).get_queryset()
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" List all course runs. """ """ List all courses runs.
---
parameters:
- name: q
description: Elasticsearch querystring query
required: false
type: string
paramType: query
multiple: false
"""
return super(CourseRunViewSet, self).list(request, *args, **kwargs) return super(CourseRunViewSet, self).list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
......
from haystack.backends.elasticsearch_backend import ElasticsearchSearchBackend, ElasticsearchSearchEngine
# pylint: disable=abstract-method
class SimplifiedElasticsearchSearchBackend(ElasticsearchSearchBackend):
def build_search_kwargs(self, *args, **kwargs):
"""
Override default `build_search_kwargs` method to set simpler default search query settings.
source:
https://github.com/django-haystack/django-haystack/blob/master/haystack/backends/elasticsearch_backend.py#L254
Without this override the default is:
'query_string': {
'default_field': content_field,
'default_operator': DEFAULT_OPERATOR,
'query': query_string,
'analyze_wildcard': True,
'auto_generate_phrase_queries': True,
'fuzzy_min_sim': FUZZY_MIN_SIM,
'fuzzy_max_expansions': FUZZY_MAX_EXPANSIONS,
}
"""
query_string = args[0]
search_kwargs = super(SimplifiedElasticsearchSearchBackend, self).build_search_kwargs(*args, **kwargs)
simple_query = {
'query': query_string,
'analyze_wildcard': True,
'auto_generate_phrase_queries': True,
}
if search_kwargs['query'].get('filtered', {}).get('query', {}).get('query_string'):
search_kwargs['query']['filtered']['query']['query_string'] = simple_query
elif search_kwargs['query'].get('query_string'):
search_kwargs['query']['query_string'] = simple_query
return search_kwargs
class SimplifiedElasticsearchSearchEngine(ElasticsearchSearchEngine):
backend = SimplifiedElasticsearchSearchBackend
""" Haystack backend tests. """
from mock import patch
from django.test import TestCase
from haystack.backends import BaseSearchBackend
from course_discovery.apps.core.haystack_backends import SimplifiedElasticsearchSearchBackend
class SimplifiedElasticsearchSearchEngineTests(TestCase):
""" Tests for core.context_processors.core """
def setUp(self):
super(SimplifiedElasticsearchSearchEngineTests, self).setUp()
self.all_query_string = "*:*"
self.specific_query_string = "tests:test query"
self.simple_query = {
'query': self.specific_query_string,
'analyze_wildcard': True,
'auto_generate_phrase_queries': True,
}
self.backend = SimplifiedElasticsearchSearchBackend(
'default',
URL='http://test-es.example.com',
INDEX_NAME='testing'
)
def test_build_search_kwargs_all_qs_with_filter(self):
with patch.object(BaseSearchBackend, 'build_models_list', return_value=['course_metadata.course']):
kwargs = self.backend.build_search_kwargs(self.all_query_string)
self.assertIsNone(kwargs['query'].get('query_string'))
self.assertIsNone(kwargs['query']['filtered']['query'].get('query_string'))
def test_build_search_kwargs_specific_qs_with_filter(self):
with patch.object(BaseSearchBackend, 'build_models_list', return_value=['course_metadata.course']):
kwargs = self.backend.build_search_kwargs(self.specific_query_string)
self.assertIsNone(kwargs['query'].get('query_string'))
self.assertDictEqual(kwargs['query']['filtered']['query'].get('query_string'), self.simple_query)
def test_build_search_kwargs_all_qs_no_filter(self):
with patch.object(BaseSearchBackend, 'build_models_list', return_value=[]):
kwargs = self.backend.build_search_kwargs(self.all_query_string)
self.assertIsNone(kwargs['query'].get('filtered'))
self.assertIsNone(kwargs['query'].get('query_string'))
def test_build_search_kwargs_specific_qs_no_filter(self):
with patch.object(BaseSearchBackend, 'build_models_list', return_value=[]):
kwargs = self.backend.build_search_kwargs(self.specific_query_string)
self.assertIsNone(kwargs['query'].get('filtered'))
self.assertDictEqual(kwargs['query'].get('query_string'), self.simple_query)
from django.db import models from django.db import models
from django.test import TestCase from django.test import TestCase
from haystack.query import SearchQuerySet
from course_discovery.apps.core.utils import get_all_related_field_names from course_discovery.apps.core.utils import get_all_related_field_names, SearchQuerySetWrapper
from course_discovery.apps.course_metadata.models import CourseRun
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory
class UnrelatedModel(models.Model): class UnrelatedModel(models.Model):
...@@ -37,3 +40,24 @@ class ModelUtilTests(TestCase): ...@@ -37,3 +40,24 @@ class ModelUtilTests(TestCase):
""" Verify the method returns the names of all relational fields for a model. """ """ Verify the method returns the names of all relational fields for a model. """
self.assertEqual(get_all_related_field_names(UnrelatedModel), []) self.assertEqual(get_all_related_field_names(UnrelatedModel), [])
self.assertEqual(set(get_all_related_field_names(RelatedModel)), {'foreignrelatedmodel', 'm2mrelatedmodel'}) self.assertEqual(set(get_all_related_field_names(RelatedModel)), {'foreignrelatedmodel', 'm2mrelatedmodel'})
class SearchQuerySetWrapperTests(TestCase):
def setUp(self):
super(SearchQuerySetWrapperTests, self).setUp()
title = 'Some random course'
query = 'title:' + title
CourseRunFactory.create_batch(3, title=title)
self.search_queryset = SearchQuerySet().models(CourseRun).raw_search(query).load_all()
self.course_runs = [e.object for e in self.search_queryset]
self.wrapper = SearchQuerySetWrapper(self.search_queryset)
def test_count(self):
self.assertEqual(self.search_queryset.count(), self.wrapper.count())
def test_iter(self):
self.assertEqual([e for e in self.course_runs], [e for e in self.wrapper])
def test_getitem(self):
self.assertEqual(self.course_runs[0], self.wrapper[0])
...@@ -58,3 +58,25 @@ def delete_orphans(model): ...@@ -58,3 +58,25 @@ def delete_orphans(model):
field_names = get_all_related_field_names(model) field_names = get_all_related_field_names(model)
kwargs = {'{0}__isnull'.format(field_name): True for field_name in field_names} kwargs = {'{0}__isnull'.format(field_name): True for field_name in field_names}
model.objects.filter(**kwargs).delete() model.objects.filter(**kwargs).delete()
class SearchQuerySetWrapper(object):
"""
Decorates a SearchQuerySet object using a generator for efficient iteration
"""
def __init__(self, qs):
self.qs = qs
def count(self):
return self.qs.count()
def __iter__(self):
for result in self.qs:
yield result.object
def __getitem__(self, key):
if isinstance(key, int) and (key >= 0 or key < self.count()):
# return the object at the specified position
return self.qs[key].object
# Pass the slice/range on to the delegate
return SearchQuerySetWrapper(self.qs[key])
...@@ -11,6 +11,7 @@ from sortedm2m.fields import SortedManyToManyField ...@@ -11,6 +11,7 @@ from sortedm2m.fields import SortedManyToManyField
from course_discovery.apps.core.models import Currency from course_discovery.apps.core.models import Currency
from course_discovery.apps.course_metadata.query import CourseQuerySet from course_discovery.apps.course_metadata.query import CourseQuerySet
from course_discovery.apps.course_metadata.utils import clean_query
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -163,8 +164,7 @@ class Course(TimeStampedModel): ...@@ -163,8 +164,7 @@ class Course(TimeStampedModel):
Returns: Returns:
QuerySet QuerySet
""" """
# NOTE (CCB): Ensure the query is lowercase, since that is how we index our data. query = clean_query(query)
query = query.lower()
results = SearchQuerySet().models(cls).raw_search(query) results = SearchQuerySet().models(cls).raw_search(query)
ids = [result.pk for result in results] ids = [result.pk for result in results]
return cls.objects.filter(pk__in=ids) return cls.objects.filter(pk__in=ids)
...@@ -255,6 +255,19 @@ class CourseRun(TimeStampedModel): ...@@ -255,6 +255,19 @@ class CourseRun(TimeStampedModel):
value = value or None value = value or None
self.full_description_override = value self.full_description_override = value
@classmethod
def search(cls, query):
""" Queries the search index.
Args:
query (str) -- Elasticsearch querystring (e.g. `title:intro*`)
Returns:
SearchQuerySet
"""
query = clean_query(query)
return SearchQuerySet().models(cls).raw_search(query).load_all()
def __str__(self): def __str__(self):
return '{key}: {title}'.format(key=self.key, title=self.title) return '{key}: {title}'.format(key=self.key, title=self.title)
......
from haystack import indexes from haystack import indexes
from opaque_keys.edx.keys import CourseKey
from course_discovery.apps.course_metadata.models import Course from course_discovery.apps.course_metadata.models import Course, CourseRun
class CourseIndex(indexes.SearchIndex, indexes.Indexable): class CourseIndex(indexes.SearchIndex, indexes.Indexable):
...@@ -40,3 +41,47 @@ class CourseIndex(indexes.SearchIndex, indexes.Indexable): ...@@ -40,3 +41,47 @@ class CourseIndex(indexes.SearchIndex, indexes.Indexable):
def get_updated_field(self): # pragma: no cover def get_updated_field(self): # pragma: no cover
return 'modified' return 'modified'
class CourseRunIndex(indexes.SearchIndex, indexes.Indexable):
text = indexes.CharField(document=True, use_template=True)
course_key = indexes.CharField(model_attr='course__key', stored=True)
key = indexes.CharField(model_attr='key', stored=True)
org = indexes.CharField()
number = indexes.CharField()
title = indexes.CharField(model_attr='title_override', null=True)
start = indexes.DateTimeField(model_attr='start', null=True)
end = indexes.DateTimeField(model_attr='end', null=True)
enrollment_start = indexes.DateTimeField(model_attr='enrollment_start', null=True)
enrollment_end = indexes.DateTimeField(model_attr='enrollment_end', null=True)
announcement = indexes.DateTimeField(model_attr='announcement', null=True)
min_effort = indexes.IntegerField(model_attr='min_effort', null=True)
max_effort = indexes.IntegerField(model_attr='max_effort', null=True)
language = indexes.CharField(null=True)
transcript_languages = indexes.MultiValueField()
pacing_type = indexes.CharField(model_attr='pacing_type', null=True)
def _prepare_language(self, language):
return '{code}: {name}'.format(code=language.code, name=language.name)
def prepare_language(self, obj):
if obj.language:
return self._prepare_language(obj.language)
return None
def prepare_number(self, obj):
course_run_key = CourseKey.from_string(obj.key)
return course_run_key.course
def prepare_org(self, obj):
course_run_key = CourseKey.from_string(obj.key)
return course_run_key.org
def prepare_transcript_languages(self, obj):
return [self._prepare_language(language) for language in obj.transcript_languages.all()]
def get_model(self):
return CourseRun
def get_updated_field(self): # pragma: no cover
return 'modified'
...@@ -4,8 +4,9 @@ import ddt ...@@ -4,8 +4,9 @@ import ddt
import pytz import pytz
from django.test import TestCase from django.test import TestCase
from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.models import ( from course_discovery.apps.course_metadata.models import (
AbstractNamedModel, AbstractMediaModel, AbstractValueModel, CourseOrganization, Course AbstractNamedModel, AbstractMediaModel, AbstractValueModel, CourseOrganization, Course, CourseRun
) )
from course_discovery.apps.course_metadata.tests import factories from course_discovery.apps.course_metadata.tests import factories
...@@ -102,6 +103,15 @@ class CourseRunTests(TestCase): ...@@ -102,6 +103,15 @@ class CourseRunTests(TestCase):
self.assertIsNone(getattr(self.course_run, override_field_name)) self.assertIsNone(getattr(self.course_run, override_field_name))
self.assertEqual(getattr(self.course_run, field_name), getattr(self.course_run.course, field_name)) self.assertEqual(getattr(self.course_run, field_name), getattr(self.course_run.course, field_name))
def test_search(self):
""" Verify the method returns a filtered queryset of course runs. """
title = 'Some random course run'
course_runs = factories.CourseRunFactory.create_batch(3, title=title)
query = 'title:' + title
actual_sorted = sorted(SearchQuerySetWrapper(CourseRun.search(query)), key=lambda course_run: course_run.key)
expected_sorted = sorted(course_runs, key=lambda course_run: course_run.key)
self.assertEqual(actual_sorted, expected_sorted)
class OrganizationTests(TestCase): class OrganizationTests(TestCase):
""" Tests for the `Organization` model. """ """ Tests for the `Organization` model. """
......
RESERVED_ELASTICSEARCH_QUERY_OPERATORS = ('AND', 'OR', 'NOT', 'TO',)
def clean_query(query):
""" Prepares a raw query for search.
Args:
query (str): query to clean.
Returns:
str: cleaned query
"""
# Ensure the query is lowercase, since that is how we index our data.
query = query.lower()
# Ensure all operators are uppercase
for operator in RESERVED_ELASTICSEARCH_QUERY_OPERATORS:
old = ' {0} '.format(operator.lower())
new = ' {0} '.format(operator.upper())
query = query.replace(old, new)
return query
...@@ -303,7 +303,7 @@ ELASTICSEARCH_INDEX_NAME = 'catalog' ...@@ -303,7 +303,7 @@ ELASTICSEARCH_INDEX_NAME = 'catalog'
HAYSTACK_CONNECTIONS = { HAYSTACK_CONNECTIONS = {
'default': { 'default': {
'ENGINE': 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine', 'ENGINE': 'course_discovery.apps.core.haystack_backends.SimplifiedElasticsearchSearchEngine',
'URL': ELASTICSEARCH_URL, 'URL': ELASTICSEARCH_URL,
'INDEX_NAME': ELASTICSEARCH_INDEX_NAME, 'INDEX_NAME': ELASTICSEARCH_INDEX_NAME,
}, },
......
...@@ -23,15 +23,7 @@ function getApiResponse(url) { ...@@ -23,15 +23,7 @@ function getApiResponse(url) {
* Form submission handler. Sends the query to the server and displays the list of courses.\ * Form submission handler. Sends the query to the server and displays the list of courses.\
*/ */
function onSubmit(e) { function onSubmit(e) {
var query = { var url = '/api/v1/course_runs/?q=' + encodeURIComponent($query.val());
"query": {
"query_string": {
"query": $query.val(),
"analyze_wildcard": true
}
}
},
url = '/api/v1/courses/?q=' + encodeURIComponent(JSON.stringify(query));
e.preventDefault(); e.preventDefault();
...@@ -56,16 +48,19 @@ function populateQueryWithExample(e) { ...@@ -56,16 +48,19 @@ function populateQueryWithExample(e) {
*/ */
function populateFieldsTable() { function populateFieldsTable() {
var data = [ var data = [
['end', 'Course end date'], ['announcement', 'Date the course is announced to the public'],
['end', 'Course run end date'],
['enrollment_start', 'Enrollment start date'], ['enrollment_start', 'Enrollment start date'],
['enrollment_end', 'Enrollment end date'], ['enrollment_end', 'Enrollment end date'],
['id', 'Course ID'], ['key', 'Course run key'],
['name', 'Course name'], ['language', 'Language in which the course is administered'],
['max_effort', 'Estimated maximum number of hours necessary to complete the course run'],
['min_effort', 'Estimated minimum number of hours necessary to complete the course run'],
['number', 'Course number (e.g. 6.002x)'], ['number', 'Course number (e.g. 6.002x)'],
['org', 'Organization (e.g. MITx)'], ['org', 'Organization (e.g. MITx)'],
['start', 'Course start date'], ['pacing_type', 'Course run pacing. Options are either "instructor_paced" or "self_paced"'],
['type', 'Type of course (audit, credit, professional, verified)'], ['start', 'Course run start date'],
['verification_deadline', 'Final date to submit identity verification'], ['title', 'Course run title']
]; ];
$("#fields").DataTable({ $("#fields").DataTable({
info: false, info: false,
...@@ -90,12 +85,15 @@ $(document).ready(function () { ...@@ -90,12 +85,15 @@ $(document).ready(function () {
paging: true, paging: true,
columns: [ columns: [
{ {
title: 'Course ID', title: 'Course Run Key',
data: 'id' data: 'key',
fnCreatedCell: function (nTd, sData, oData, iRow, iCol) {
$(nTd).html("<a href='/api/v1/course_runs/" + oData.key + "/' target='_blank'>" + oData.key + "</a>");
}
}, },
{ {
title: 'Name', title: 'Title',
data: 'name' data: 'title'
} }
], ],
oLanguage: { oLanguage: {
......
...@@ -65,10 +65,6 @@ ...@@ -65,10 +65,6 @@
<td><a class="example">org:(-MITx OR -HarvardX)</a></td> <td><a class="example">org:(-MITx OR -HarvardX)</a></td>
</tr> </tr>
<tr> <tr>
<td>Courses of a particular type. Options include audit, credit, honor, professional, verified.</td>
<td><a class="example">type:credit</a></td>
</tr>
<tr>
<td>Courses starting in a specific time period</td> <td>Courses starting in a specific time period</td>
<td><a class="example">start:[2016-01-01 TO 2016-12-31]</a></td> <td><a class="example">start:[2016-01-01 TO 2016-12-31]</a></td>
</tr> </tr>
...@@ -108,7 +104,7 @@ ...@@ -108,7 +104,7 @@
<table id="courses" class="table table-striped table-bordered" cellspacing="0"> <table id="courses" class="table table-striped table-bordered" cellspacing="0">
<thead> <thead>
<tr> <tr>
<th>Course ID</th> <th>Course Run Key</th>
<th>Name</th> <th>Name</th>
</tr> </tr>
</thead> </thead>
......
{{ object.key }}
{{ object.title }}
{{ object.short_description|default:'' }}
{{ object.full_description|default:'' }}
{{ object.pacing_type|default:'' }}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment