Commit a4bed7bd by Clinton Blackburn Committed by GitHub

Merge pull request #159 from edx/clintonb/programs-search

Added programs search
parents 55d6f543 4439723f
...@@ -46,7 +46,7 @@ class FacetQueryBuilderWithQueries(FacetQueryBuilder): ...@@ -46,7 +46,7 @@ class FacetQueryBuilderWithQueries(FacetQueryBuilder):
def build_query(self, **filters): def build_query(self, **filters):
query = super(FacetQueryBuilderWithQueries, self).build_query(**filters) query = super(FacetQueryBuilderWithQueries, self).build_query(**filters)
facet_serializer_cls = self.view.get_facet_serializer_class() facet_serializer_cls = self.view.get_facet_serializer_class()
query['query_facets'] = facet_serializer_cls.Meta.field_queries query['query_facets'] = getattr(facet_serializer_cls.Meta, 'field_queries', {})
return query return query
......
...@@ -12,7 +12,7 @@ from course_discovery.apps.catalogs.models import Catalog ...@@ -12,7 +12,7 @@ from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.course_metadata.models import ( from course_discovery.apps.course_metadata.models import (
Course, CourseRun, Image, Organization, Person, Prerequisite, Seat, Subject, Video Course, CourseRun, Image, Organization, Person, Prerequisite, Seat, Subject, Video
) )
from course_discovery.apps.course_metadata.search_indexes import CourseIndex, CourseRunIndex from course_discovery.apps.course_metadata.search_indexes import CourseIndex, CourseRunIndex, ProgramIndex
User = get_user_model() User = get_user_model()
...@@ -415,7 +415,7 @@ class BaseHaystackFacetSerializer(HaystackFacetSerializer): ...@@ -415,7 +415,7 @@ class BaseHaystackFacetSerializer(HaystackFacetSerializer):
_abstract = True _abstract = True
def get_fields(self): def get_fields(self):
query_facet_counts = self.instance.pop('queries') query_facet_counts = self.instance.pop('queries', {})
field_mapping = super(BaseHaystackFacetSerializer, self).get_fields() field_mapping = super(BaseHaystackFacetSerializer, self).get_fields()
...@@ -432,7 +432,7 @@ class BaseHaystackFacetSerializer(HaystackFacetSerializer): ...@@ -432,7 +432,7 @@ class BaseHaystackFacetSerializer(HaystackFacetSerializer):
def format_query_facet_data(self, query_facet_counts): def format_query_facet_data(self, query_facet_counts):
query_data = {} query_data = {}
for field, options in self.Meta.field_queries.items(): # pylint: disable=no-member for field, options in getattr(self.Meta, 'field_queries', {}).items(): # pylint: disable=no-member
count = query_facet_counts.get(field, 0) count = query_facet_counts.get(field, 0)
if count: if count:
query_data[field] = { query_data[field] = {
...@@ -468,8 +468,6 @@ class CourseFacetSerializer(BaseHaystackFacetSerializer): ...@@ -468,8 +468,6 @@ class CourseFacetSerializer(BaseHaystackFacetSerializer):
class CourseRunSearchSerializer(HaystackSerializer): class CourseRunSearchSerializer(HaystackSerializer):
content_type = serializers.CharField(source='model_name')
class Meta: class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = COURSE_RUN_SEARCH_FIELDS fields = COURSE_RUN_SEARCH_FIELDS
...@@ -487,6 +485,24 @@ class CourseRunFacetSerializer(BaseHaystackFacetSerializer): ...@@ -487,6 +485,24 @@ class CourseRunFacetSerializer(BaseHaystackFacetSerializer):
ignore_fields = COMMON_IGNORED_FIELDS ignore_fields = COMMON_IGNORED_FIELDS
class ProgramSearchSerializer(HaystackSerializer):
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = ('uuid', 'name', 'subtitle', 'category', 'marketing_url', 'organizations', 'text',)
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [ProgramIndex]
class ProgramFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = ('uuid', 'name', 'subtitle', 'category', 'marketing_url', 'organizations', 'text',)
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [ProgramIndex]
class AggregateSearchSerializer(HaystackSerializer): class AggregateSearchSerializer(HaystackSerializer):
class Meta: class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES field_aliases = COMMON_SEARCH_FIELD_ALIASES
...@@ -495,6 +511,7 @@ class AggregateSearchSerializer(HaystackSerializer): ...@@ -495,6 +511,7 @@ class AggregateSearchSerializer(HaystackSerializer):
serializers = { serializers = {
CourseRunIndex: CourseRunSearchSerializer, CourseRunIndex: CourseRunSearchSerializer,
CourseIndex: CourseSearchSerializer, CourseIndex: CourseSearchSerializer,
ProgramIndex: ProgramSearchSerializer,
} }
...@@ -509,4 +526,5 @@ class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer): ...@@ -509,4 +526,5 @@ class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
serializers = { serializers = {
CourseRunIndex: CourseRunFacetSerializer, CourseRunIndex: CourseRunFacetSerializer,
CourseIndex: CourseFacetSerializer, CourseIndex: CourseFacetSerializer,
ProgramIndex: ProgramFacetSerializer,
} }
...@@ -11,14 +11,16 @@ from course_discovery.apps.api.serializers import ( ...@@ -11,14 +11,16 @@ from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseSerializer, CourseRunSerializer, ContainedCoursesSerializer, ImageSerializer, CatalogSerializer, CourseSerializer, CourseRunSerializer, ContainedCoursesSerializer, ImageSerializer,
SubjectSerializer, PrerequisiteSerializer, VideoSerializer, OrganizationSerializer, SeatSerializer, SubjectSerializer, PrerequisiteSerializer, VideoSerializer, OrganizationSerializer, SeatSerializer,
PersonSerializer, AffiliateWindowSerializer, ContainedCourseRunsSerializer, PersonSerializer, AffiliateWindowSerializer, ContainedCourseRunsSerializer,
CourseRunSearchSerializer) CourseRunSearchSerializer, ProgramSearchSerializer
)
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
from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.course_metadata.models import CourseRun from course_discovery.apps.course_metadata.models import CourseRun, Program
from course_discovery.apps.course_metadata.search_indexes import OrganizationsMixin
from course_discovery.apps.course_metadata.tests.factories import ( from course_discovery.apps.course_metadata.tests.factories import (
CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory, CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory, ImageFactory, VideoFactory,
ImageFactory, VideoFactory, OrganizationFactory, PersonFactory, SeatFactory OrganizationFactory, PersonFactory, SeatFactory, ProgramFactory
) )
...@@ -358,3 +360,26 @@ class CourseRunSearchSerializerTests(TestCase): ...@@ -358,3 +360,26 @@ class CourseRunSearchSerializerTests(TestCase):
'type': course_run.type, 'type': course_run.type,
} }
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
class ProgramSearchSerializerTests(TestCase):
def test_data(self):
program = ProgramFactory()
organization = OrganizationFactory()
program.organizations.add(organization)
program.save()
# NOTE: This serializer expects SearchQuerySet results, so we run a search on the newly-created object
# to generate such a result.
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
serializer = ProgramSearchSerializer(result)
expected = {
'uuid': str(program.uuid),
'name': program.name,
'subtitle': program.subtitle,
'category': program.category,
'marketing_url': program.marketing_url,
'organizations': [OrganizationsMixin.format_organization(organization)],
}
self.assertDictEqual(serializer.data, expected)
...@@ -19,5 +19,6 @@ router.register(r'management', views.ManagementViewSet, base_name='management') ...@@ -19,5 +19,6 @@ router.register(r'management', views.ManagementViewSet, base_name='management')
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/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')
urlpatterns += router.urls urlpatterns += router.urls
...@@ -29,7 +29,7 @@ from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer, Cour ...@@ -29,7 +29,7 @@ from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer, Cour
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.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, Program
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
...@@ -360,14 +360,14 @@ class AffiliateWindowViewSet(viewsets.ViewSet): ...@@ -360,14 +360,14 @@ class AffiliateWindowViewSet(viewsets.ViewSet):
return Response(serializer.data) return Response(serializer.data)
class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet): class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
document_uid_field = 'key' document_uid_field = 'key'
facet_filter_backends = [HaystackFacetFilterWithQueries, HaystackFilter] facet_filter_backends = [HaystackFacetFilterWithQueries, HaystackFilter]
load_all = True load_all = True
lookup_field = 'key' lookup_field = 'key'
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
# NOTE: We use PageNumberPagination because drf-haytack's facet serializer relies on the page_query_param # NOTE: We use PageNumberPagination because drf-haystack's facet serializer relies on the page_query_param
# attribute, and it is more appropriate for search results than our default limit-offset pagination. # attribute, and it is more appropriate for search results than our default limit-offset pagination.
pagination_class = PageNumberPagination pagination_class = PageNumberPagination
...@@ -382,7 +382,7 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet): ...@@ -382,7 +382,7 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
type: string type: string
required: false required: false
""" """
return super(BaseCourseHaystackViewSet, self).list(request, *args, **kwargs) return super(BaseHaystackViewSet, self).list(request, *args, **kwargs)
@list_route(methods=["get"], url_path="facets") @list_route(methods=["get"], url_path="facets")
def facets(self, request): def facets(self, request):
...@@ -412,13 +412,13 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet): ...@@ -412,13 +412,13 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
pytype: str pytype: str
required: false required: false
""" """
return super(BaseCourseHaystackViewSet, self).facets(request) return super(BaseHaystackViewSet, self).facets(request)
def filter_facet_queryset(self, queryset): def filter_facet_queryset(self, queryset):
queryset = super(BaseCourseHaystackViewSet, self).filter_facet_queryset(queryset) queryset = super(BaseHaystackViewSet, self).filter_facet_queryset(queryset)
facet_serializer_cls = self.get_facet_serializer_class() facet_serializer_cls = self.get_facet_serializer_class()
field_queries = facet_serializer_cls.Meta.field_queries field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {})
for facet in self.request.query_params.getlist('selected_query_facets'): for facet in self.request.query_params.getlist('selected_query_facets'):
query = field_queries.get(facet) query = field_queries.get(facet)
...@@ -431,20 +431,27 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet): ...@@ -431,20 +431,27 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
return queryset return queryset
class CourseSearchViewSet(BaseCourseHaystackViewSet): class CourseSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseFacetSerializer facet_serializer_class = serializers.CourseFacetSerializer
index_models = (Course,) index_models = (Course,)
serializer_class = serializers.CourseSearchSerializer serializer_class = serializers.CourseSearchSerializer
class CourseRunSearchViewSet(BaseCourseHaystackViewSet): class CourseRunSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseRunFacetSerializer facet_serializer_class = serializers.CourseRunFacetSerializer
index_models = (CourseRun,) index_models = (CourseRun,)
serializer_class = serializers.CourseRunSearchSerializer serializer_class = serializers.CourseRunSearchSerializer
# TODO Remove the detail routes. They don't work, and make no sense here given that we cannot specify the type. class ProgramSearchViewSet(BaseHaystackViewSet):
class AggregateSearchViewSet(BaseCourseHaystackViewSet): document_uid_field = 'uuid'
lookup_field = 'uuid'
facet_serializer_class = serializers.ProgramFacetSerializer
index_models = (Program,)
serializer_class = serializers.ProgramSearchSerializer
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
import datetime import datetime
import logging import logging
from urllib.parse import urljoin
from uuid import uuid4 from uuid import uuid4
import pytz import pytz
from django.conf import settings
from django.db import models from django.db import models
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
...@@ -425,5 +427,13 @@ class Program(TimeStampedModel): ...@@ -425,5 +427,13 @@ class Program(TimeStampedModel):
organizations = models.ManyToManyField(Organization, blank=True) organizations = models.ManyToManyField(Organization, blank=True)
@property
def marketing_url(self):
if self.marketing_slug:
path = '{category}/{slug}'.format(category=self.category, slug=self.marketing_slug)
return urljoin(settings.MARKETING_URL_ROOT, path)
return None
def __str__(self): def __str__(self):
return self.name return self.name
from haystack import indexes from haystack import indexes
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from course_discovery.apps.course_metadata.models import Course, CourseRun from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
class OrganizationsMixin:
@classmethod
def format_organization(cls, organization):
return '{key}: {name}'.format(key=organization.key, name=organization.name)
def prepare_organizations(self, obj):
return [self.format_organization(organization) for organization in obj.organizations.all()]
class BaseIndex(indexes.SearchIndex): class BaseIndex(indexes.SearchIndex):
...@@ -23,7 +32,7 @@ class BaseIndex(indexes.SearchIndex): ...@@ -23,7 +32,7 @@ class BaseIndex(indexes.SearchIndex):
return self.model.objects.all() return self.model.objects.all()
class BaseCourseIndex(BaseIndex): class BaseCourseIndex(OrganizationsMixin, BaseIndex):
key = indexes.CharField(model_attr='key', stored=True) key = indexes.CharField(model_attr='key', stored=True)
title = indexes.CharField(model_attr='title') title = indexes.CharField(model_attr='title')
short_description = indexes.CharField(model_attr='short_description', null=True) short_description = indexes.CharField(model_attr='short_description', null=True)
...@@ -31,10 +40,6 @@ class BaseCourseIndex(BaseIndex): ...@@ -31,10 +40,6 @@ class BaseCourseIndex(BaseIndex):
subjects = indexes.MultiValueField(faceted=True) subjects = indexes.MultiValueField(faceted=True)
organizations = indexes.MultiValueField(faceted=True) organizations = indexes.MultiValueField(faceted=True)
def prepare_organizations(self, obj):
return ['{key}: {name}'.format(key=organization.key, name=organization.name) for organization in
obj.organizations.all()]
def prepare_subjects(self, obj): def prepare_subjects(self, obj):
return [subject.name for subject in obj.subjects.all()] return [subject.name for subject in obj.subjects.all()]
...@@ -97,3 +102,17 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -97,3 +102,17 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
def prepare_transcript_languages(self, obj): def prepare_transcript_languages(self, obj):
return [self._prepare_language(language) for language in obj.transcript_languages.all()] return [self._prepare_language(language) for language in obj.transcript_languages.all()]
class ProgramIndex(OrganizationsMixin, BaseIndex, indexes.Indexable):
model = Program
uuid = indexes.CharField(model_attr='uuid')
name = indexes.CharField(model_attr='name')
subtitle = indexes.CharField(model_attr='subtitle')
category = indexes.CharField(model_attr='category', faceted=True)
marketing_url = indexes.CharField(model_attr='marketing_url', null=True)
organizations = indexes.MultiValueField(faceted=True)
def prepare_content_type(self, obj):
return 'program_{category}'.format(category=obj.category)
...@@ -3,6 +3,7 @@ import datetime ...@@ -3,6 +3,7 @@ import datetime
import ddt import ddt
import mock import mock
import pytz import pytz
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from course_discovery.apps.core.utils import SearchQuerySetWrapper from course_discovery.apps.core.utils import SearchQuerySetWrapper
...@@ -243,6 +244,16 @@ class ProgramTests(TestCase): ...@@ -243,6 +244,16 @@ class ProgramTests(TestCase):
def test_str(self): def test_str(self):
"""Verify that a program is properly converted to a str.""" """Verify that a program is properly converted to a str."""
program = factories.ProgramFactory() program = factories.ProgramFactory()
program_str = program.name self.assertEqual(str(program), program.name)
self.assertEqual(str(program), program_str) def test_marketing_url(self):
""" Verify the property creates a complete marketing URL. """
program = factories.ProgramFactory()
expected = '{root}/{category}/{slug}'.format(root=settings.MARKETING_URL_ROOT.strip('/'),
category=program.category, slug=program.marketing_slug)
self.assertEqual(program.marketing_url, expected)
def test_marketing_url_without_slug(self):
""" Verify the property returns None if the Program has no marketing_slug set. """
program = factories.ProgramFactory(marketing_slug='')
self.assertIsNone(program.marketing_url)
{{ object.uuid }}
{{ object.name }}
{{ object.category }}
{{ object.status }}
{{ object.marketing_slug|default:'' }}
{% for organization in object.organizations.all %}
{{ organization.key }}: {{ organization.name }}
{% endfor %}
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