Commit 4439723f by Clinton Blackburn Committed by Clinton Blackburn

Added programs search

ECOM-4801
parent c89b6cb3
...@@ -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()
...@@ -485,6 +485,24 @@ class CourseRunFacetSerializer(BaseHaystackFacetSerializer): ...@@ -485,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
...@@ -493,6 +511,7 @@ class AggregateSearchSerializer(HaystackSerializer): ...@@ -493,6 +511,7 @@ class AggregateSearchSerializer(HaystackSerializer):
serializers = { serializers = {
CourseRunIndex: CourseRunSearchSerializer, CourseRunIndex: CourseRunSearchSerializer,
CourseIndex: CourseSearchSerializer, CourseIndex: CourseSearchSerializer,
ProgramIndex: ProgramSearchSerializer,
} }
...@@ -507,4 +526,5 @@ class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer): ...@@ -507,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,10 +412,10 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet): ...@@ -412,10 +412,10 @@ 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 = getattr(facet_serializer_cls.Meta, 'field_queries', {}) field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {})
...@@ -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