diff --git a/lms/djangoapps/course_api/forms.py b/lms/djangoapps/course_api/forms.py index 85bfd4d896..9e548dede1 100644 --- a/lms/djangoapps/course_api/forms.py +++ b/lms/djangoapps/course_api/forms.py @@ -48,6 +48,7 @@ class CourseListGetForm(UsernameValidatorMixin, Form): """ A form to validate query parameters in the course list retrieval endpoint """ + search_term = CharField(required=False) username = CharField(required=False) org = CharField(required=False) diff --git a/lms/djangoapps/course_api/tests/test_forms.py b/lms/djangoapps/course_api/tests/test_forms.py index 5ebff7e571..58b612db1b 100644 --- a/lms/djangoapps/course_api/tests/test_forms.py +++ b/lms/djangoapps/course_api/tests/test_forms.py @@ -66,6 +66,7 @@ class TestCourseListGetForm(FormTestMixin, UsernameTestMixin, SharedModuleStoreT 'username': user.username, 'org': '', 'mobile': None, + 'search_term': '', 'filter_': None, } diff --git a/lms/djangoapps/course_api/tests/test_views.py b/lms/djangoapps/course_api/tests/test_views.py index 199d300848..dba489995e 100644 --- a/lms/djangoapps/course_api/tests/test_views.py +++ b/lms/djangoapps/course_api/tests/test_views.py @@ -5,7 +5,11 @@ from hashlib import md5 from django.core.urlresolvers import reverse from django.test import RequestFactory +from django.test.utils import override_settings from nose.plugins.attrib import attr +from search.tests.test_course_discovery import DemoCourse +from search.tests.tests import TEST_INDEX_NAME +from search.tests.utils import SearcherMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase @@ -223,3 +227,85 @@ class CourseDetailViewTestCase(CourseApiTestViewMixin, SharedModuleStoreTestCase request.user = self.staff_user response = CourseDetailView().dispatch(request, course_key_string='a:b:c') self.assertEquals(response.status_code, 400) + + +@override_settings(ELASTIC_FIELD_MAPPINGS={ + 'start_date': {'type': 'date'}, + 'enrollment_start': {'type': 'date'}, + 'enrollment_end': {'type': 'date'} +}) +@override_settings(SEARCH_ENGINE="search.tests.mock_search_engine.MockSearchEngine") +@override_settings(COURSEWARE_INDEX_NAME=TEST_INDEX_NAME) +class CourseListSearchViewTest(CourseApiTestViewMixin, ModuleStoreTestCase, SearcherMixin): + """ + Tests the search functionality of the courses API. + + Similar to search.tests.test_course_discovery_views but with the course API integration. + """ + + ENABLED_SIGNALS = ['course_published'] + + def setUp(self): + super(CourseListSearchViewTest, self).setUp() + DemoCourse.reset_count() + self.searcher.destroy() + + self.courses = [ + self.create_and_index_course('OrgA', 'Find this one with the right parameter'), + self.create_and_index_course('OrgB', 'Find this one with another parameter'), + self.create_and_index_course('OrgC', 'This course has a unique search term'), + ] + + self.url = reverse('course-list') + self.staff_user = self.create_user(username='staff', is_staff=True) + self.honor_user = self.create_user(username='honor', is_staff=False) + + def create_and_index_course(self, org_code, short_description): + """ + Add a course to both database and search. + + Warning: A ton of gluing here! If this fails, double check both CourseListViewTestCase and MockSearchUrlTest. + """ + + search_course = DemoCourse.get({ + 'org': org_code, + 'run': '2010', + 'number': 'DemoZ', + # Using the slash separated course ID bcuz `DemoCourse` isn't updated yet to new locator. + 'id': '{org_code}/DemoZ/2010'.format(org_code=org_code), + 'content': { + 'short_description': short_description, + }, + }) + + DemoCourse.index(self.searcher, [search_course]) + + org, course, run = search_course['id'].split('/') + + db_course = self.create_course( + mobile_available=False, + org=org, + course=course, + run=run, + short_description=short_description, + ) + + return db_course + + def test_list_all(self): + """ + Test without search, should list all the courses. + """ + res = self.verify_response() + self.assertIn('results', res.data) + self.assertNotEqual(res.data['results'], []) + self.assertEqual(res.data['pagination']['count'], 3) # Should list all of the 3 courses + + def test_list_all_with_search_term(self): + """ + Test with search, should only the course that matches the search term. + """ + res = self.verify_response(params={'search_term': 'unique search term'}) + self.assertIn('results', res.data) + self.assertNotEqual(res.data['results'], []) + self.assertEqual(res.data['pagination']['count'], 1) # Should list a single course diff --git a/lms/djangoapps/course_api/views.py b/lms/djangoapps/course_api/views.py index 9eaad3fae5..0caeeed376 100644 --- a/lms/djangoapps/course_api/views.py +++ b/lms/djangoapps/course_api/views.py @@ -2,6 +2,8 @@ Course API Views """ +import search +from django.conf import settings from django.core.exceptions import ValidationError from rest_framework.generics import ListAPIView, RetrieveAPIView @@ -137,6 +139,8 @@ class CourseListView(DeveloperErrorViewMixin, ListAPIView): Body comprises a list of objects as returned by `CourseDetailView`. **Parameters** + search_term (optional): + Search term to filter courses (used by ElasticSearch). username (optional): The username of the specified user whose visible courses we @@ -193,6 +197,11 @@ class CourseListView(DeveloperErrorViewMixin, ListAPIView): pagination_class = NamespacedPageNumberPagination serializer_class = CourseSerializer + # Return all the results, 10K is the maximum allowed value for ElasticSearch. + # We should use 0 after upgrading to 1.1+: + # - https://github.com/elastic/elasticsearch/commit/8b0a863d427b4ebcbcfb1dcd69c996c52e7ae05e + results_size_infinity = 10000 + def get_queryset(self): """ Return a list of courses visible to the user. @@ -201,9 +210,24 @@ class CourseListView(DeveloperErrorViewMixin, ListAPIView): if not form.is_valid(): raise ValidationError(form.errors) - return list_courses( + db_courses = list_courses( self.request, form.cleaned_data['username'], org=form.cleaned_data['org'], filter_=form.cleaned_data['filter_'], ) + + if not settings.FEATURES['ENABLE_COURSEWARE_SEARCH'] or not form.cleaned_data['search_term']: + return db_courses + + search_courses = search.api.course_discovery_search( + form.cleaned_data['search_term'], + size=self.results_size_infinity, + ) + + search_courses_ids = {course['data']['id']: True for course in search_courses['results']} + + return [ + course for course in db_courses + if unicode(course.id) in search_courses_ids + ]