Commit f9187b85 by Matthew Piatetsky Committed by GitHub

Merge pull request #447 from edx/ECOM-6377

ECOM-4738 Add Typeahead endpoint to course discovery
parents 95a419d0 319ebd2c
...@@ -913,6 +913,20 @@ class CourseRunSearchSerializer(HaystackSerializer): ...@@ -913,6 +913,20 @@ class CourseRunSearchSerializer(HaystackSerializer):
index_classes = [CourseRunIndex] index_classes = [CourseRunIndex]
class TypeaheadCourseRunSearchSerializer(HaystackSerializer):
additional_details = serializers.SerializerMethodField()
def get_additional_details(self, result):
""" Value of the grey text next to the typeahead result title. """
return result.org
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = ['key', 'title', 'content_type']
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [CourseRunIndex]
class CourseRunFacetSerializer(BaseHaystackFacetSerializer): class CourseRunFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True serialize_objects = True
...@@ -938,6 +952,21 @@ class ProgramSearchSerializer(HaystackSerializer): ...@@ -938,6 +952,21 @@ class ProgramSearchSerializer(HaystackSerializer):
index_classes = [ProgramIndex] index_classes = [ProgramIndex]
class TypeaheadProgramSearchSerializer(HaystackSerializer):
additional_details = serializers.SerializerMethodField()
def get_additional_details(self, result):
""" Value of the grey text next to the typeahead result title. """
authoring_organizations = [json.loads(org) for org in result.authoring_organization_bodies]
return ', '.join([org['key'] for org in authoring_organizations])
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = ['uuid', 'title', 'content_type', 'type']
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [ProgramIndex]
class ProgramFacetSerializer(BaseHaystackFacetSerializer): class ProgramFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True serialize_objects = True
...@@ -961,6 +990,17 @@ class AggregateSearchSerializer(HaystackSerializer): ...@@ -961,6 +990,17 @@ class AggregateSearchSerializer(HaystackSerializer):
} }
class TypeaheadSearchSerializer(HaystackSerializer):
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = COURSE_RUN_SEARCH_FIELDS + PROGRAM_SEARCH_FIELDS
ignore_fields = COMMON_IGNORED_FIELDS
serializers = {
ProgramIndex: TypeaheadProgramSearchSerializer,
CourseRunIndex: TypeaheadCourseRunSearchSerializer,
}
class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer): class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
serialize_objects = True serialize_objects = True
......
...@@ -17,7 +17,7 @@ from course_discovery.apps.api.serializers import ( ...@@ -17,7 +17,7 @@ from course_discovery.apps.api.serializers import (
CourseRunWithProgramsSerializer, CourseWithProgramsSerializer, CorporateEndorsementSerializer, CourseRunWithProgramsSerializer, CourseWithProgramsSerializer, CorporateEndorsementSerializer,
FAQSerializer, EndorsementSerializer, PositionSerializer, FlattenedCourseRunWithCourseSerializer, FAQSerializer, EndorsementSerializer, PositionSerializer, FlattenedCourseRunWithCourseSerializer,
MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalCourseRunSerializer, MinimalProgramSerializer, MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalCourseRunSerializer, MinimalProgramSerializer,
CourseSerializer CourseSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer
) )
from course_discovery.apps.catalogs.tests.factories import CatalogFactory from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.models import User from course_discovery.apps.core.models import User
...@@ -1099,3 +1099,57 @@ class ProgramSearchSerializerTests(TestCase): ...@@ -1099,3 +1099,57 @@ class ProgramSearchSerializerTests(TestCase):
expected = self._create_expected_data(program) expected = self._create_expected_data(program)
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
class TypeaheadCourseRunSearchSerializerTests(TestCase):
def test_data(self):
course_run = CourseRunFactory()
serialized_course = self.serialize_course_run(course_run)
course_run_key = CourseKey.from_string(course_run.key)
expected = {
'key': course_run.key,
'title': course_run.title,
'content_type': 'courserun',
'additional_details': course_run_key.org
}
self.assertDictEqual(serialized_course.data, expected)
def serialize_course_run(self, course_run):
""" Serializes the given `CourseRun` as a typeahead result. """
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
serializer = TypeaheadCourseRunSearchSerializer(result)
return serializer
class TypeaheadProgramSearchSerializerTests(TestCase):
def _create_expected_data(self, program):
return {
'uuid': str(program.uuid),
'title': program.title,
'type': program.type.name,
'content_type': 'program',
'additional_details': program.authoring_organizations.first().key
}
def test_data(self):
authoring_organization = OrganizationFactory()
program = ProgramFactory(authoring_organizations=[authoring_organization])
serialized_program = self.serialize_program(program)
expected = self._create_expected_data(program)
self.assertDictEqual(serialized_program.data, expected)
def test_data_multiple_authoring_organizations(self):
authoring_organizations = OrganizationFactory.create_batch(3)
program = ProgramFactory(authoring_organizations=authoring_organizations)
serialized_program = self.serialize_program(program)
expected = ', '.join([org.key for org in authoring_organizations])
self.assertEqual(serialized_program.data['additional_details'], expected)
def serialize_program(self, program):
""" Serializes the given `Program` as a typeahead result. """
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
serializer = TypeaheadProgramSearchSerializer(result)
return serializer
...@@ -8,12 +8,15 @@ from django.core.urlresolvers import reverse ...@@ -8,12 +8,15 @@ from django.core.urlresolvers import reverse
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from course_discovery.apps.api.serializers import CourseRunSearchSerializer, ProgramSearchSerializer from course_discovery.apps.api.serializers import (CourseRunSearchSerializer, ProgramSearchSerializer,
TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer)
from course_discovery.apps.api.v1.views import RESULT_COUNT
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD, PartnerFactory from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD, PartnerFactory
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.models import CourseRun, Program from course_discovery.apps.course_metadata.models import CourseRun, Program
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory, OrganizationFactory
class SerializationMixin: class SerializationMixin:
...@@ -26,6 +29,22 @@ class SerializationMixin: ...@@ -26,6 +29,22 @@ class SerializationMixin:
return ProgramSearchSerializer(result).data return ProgramSearchSerializer(result).data
class TypeaheadSerializationMixin:
def serialize_course_run(self, course_run):
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
data = TypeaheadCourseRunSearchSerializer(result).data
# Items are grouped by content type so we don't need it in the response
data.pop('content_type')
return data
def serialize_program(self, program):
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
data = TypeaheadProgramSearchSerializer(result).data
# Items are grouped by content type so we don't need it in the response
data.pop('content_type')
return data
class LoginMixin: class LoginMixin:
def setUp(self): def setUp(self):
super(LoginMixin, self).setUp() super(LoginMixin, self).setUp()
...@@ -279,3 +298,41 @@ class AggregateSearchViewSet(DefaultPartnerMixin, SerializationMixin, LoginMixin ...@@ -279,3 +298,41 @@ class AggregateSearchViewSet(DefaultPartnerMixin, SerializationMixin, LoginMixin
response_data = json.loads(response.content.decode('utf-8')) response_data = json.loads(response.content.decode('utf-8'))
self.assertListEqual(response_data['objects']['results'], self.assertListEqual(response_data['objects']['results'],
[self.serialize_course_run(course_run), self.serialize_program(program)]) [self.serialize_course_run(course_run), self.serialize_program(program)])
class TypeaheadSearchViewSet(TypeaheadSerializationMixin, LoginMixin, APITestCase):
path = reverse('api:v1:search-typeahead-list')
def get_typeahead_response(self):
return self.client.get(self.path)
def test_typeahead(self):
""" Test typeahead response. """
course_run = CourseRunFactory()
program = ProgramFactory()
response = self.get_typeahead_response()
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertDictEqual(response_data, {'course_runs': [self.serialize_course_run(course_run)],
'programs': [self.serialize_program(program)]})
def test_typeahead_multiple_results(self):
""" Test typeahead response with max number of course_runs and programs. """
CourseRunFactory.create_batch(RESULT_COUNT + 1)
ProgramFactory.create_batch(RESULT_COUNT + 1)
response = self.get_typeahead_response()
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertEqual(len(response_data['course_runs']), RESULT_COUNT)
self.assertEqual(len(response_data['programs']), RESULT_COUNT)
def test_typeahead_multiple_authoring_organizations(self):
""" Test typeahead response with multiple authoring organizations. """
authoring_organizations = OrganizationFactory.create_batch(3)
course_run = CourseRunFactory(authoring_organizations=authoring_organizations)
program = ProgramFactory(authoring_organizations=authoring_organizations)
response = self.get_typeahead_response()
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertDictEqual(response_data, {'course_runs': [self.serialize_course_run(course_run)],
'programs': [self.serialize_program(program)]})
...@@ -18,6 +18,7 @@ router.register(r'course_runs', views.CourseRunViewSet, base_name='course_run') ...@@ -18,6 +18,7 @@ router.register(r'course_runs', views.CourseRunViewSet, base_name='course_run')
router.register(r'management', views.ManagementViewSet, base_name='management') router.register(r'management', views.ManagementViewSet, base_name='management')
router.register(r'programs', views.ProgramViewSet, base_name='program') router.register(r'programs', views.ProgramViewSet, base_name='program')
router.register(r'search/all', views.AggregateSearchViewSet, base_name='search-all') router.register(r'search/all', views.AggregateSearchViewSet, base_name='search-all')
router.register(r'search/typeahead', views.TypeaheadSearchViewSet, base_name='search-typeahead')
router.register(r'search/courses', views.CourseSearchViewSet, base_name='search-courses') router.register(r'search/courses', views.CourseSearchViewSet, base_name='search-courses')
router.register(r'search/course_runs', views.CourseRunSearchViewSet, base_name='search-course_runs') router.register(r'search/course_runs', views.CourseRunSearchViewSet, base_name='search-course_runs')
router.register(r'search/programs', views.ProgramSearchViewSet, base_name='search-programs') router.register(r'search/programs', views.ProgramSearchViewSet, base_name='search-programs')
......
...@@ -37,6 +37,8 @@ from course_discovery.apps.course_metadata.models import Course, CourseRun, Part ...@@ -37,6 +37,8 @@ from course_discovery.apps.course_metadata.models import Course, CourseRun, Part
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
RESULT_COUNT = 3
def get_query_param(request, name): def get_query_param(request, name):
""" """
...@@ -676,3 +678,30 @@ class AggregateSearchViewSet(BaseHaystackViewSet): ...@@ -676,3 +678,30 @@ class AggregateSearchViewSet(BaseHaystackViewSet):
""" Search all content types. """ """ Search all content types. """
facet_serializer_class = serializers.AggregateFacetSearchSerializer facet_serializer_class = serializers.AggregateFacetSearchSerializer
serializer_class = serializers.AggregateSearchSerializer serializer_class = serializers.AggregateSearchSerializer
class TypeaheadSearchViewSet(BaseHaystackViewSet):
"""
Typeahead for courses and programs.
"""
serializer_class = serializers.TypeaheadSearchSerializer
index_models = (CourseRun, Program,)
def list(self, request, *args, **kwargs):
response = super(TypeaheadSearchViewSet, self).list(request, *args, **kwargs)
results = response.data['results']
course_runs, programs = [], []
for item in results:
# Items are grouped by content type so we don't need it in the response
item_type = item.pop('content_type', None)
programs_length = len(programs)
course_run_length = len(course_runs)
if item_type == 'courserun' and course_run_length < RESULT_COUNT:
course_runs.append(item)
elif item_type == 'program' and programs_length < RESULT_COUNT:
programs.append(item)
elif programs_length == RESULT_COUNT and course_run_length == RESULT_COUNT:
break
response.data = {'course_runs': course_runs, 'programs': programs}
return response
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