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
from course_discovery.apps.course_metadata.models import (
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()
......@@ -485,6 +485,24 @@ class CourseRunFacetSerializer(BaseHaystackFacetSerializer):
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 Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
......@@ -493,6 +511,7 @@ class AggregateSearchSerializer(HaystackSerializer):
serializers = {
CourseRunIndex: CourseRunSearchSerializer,
CourseIndex: CourseSearchSerializer,
ProgramIndex: ProgramSearchSerializer,
}
......@@ -507,4 +526,5 @@ class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
serializers = {
CourseRunIndex: CourseRunFacetSerializer,
CourseIndex: CourseFacetSerializer,
ProgramIndex: ProgramFacetSerializer,
}
......@@ -11,14 +11,16 @@ from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseSerializer, CourseRunSerializer, ContainedCoursesSerializer, ImageSerializer,
SubjectSerializer, PrerequisiteSerializer, VideoSerializer, OrganizationSerializer, SeatSerializer,
PersonSerializer, AffiliateWindowSerializer, ContainedCourseRunsSerializer,
CourseRunSearchSerializer)
CourseRunSearchSerializer, ProgramSearchSerializer
)
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.models import User
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 (
CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory,
ImageFactory, VideoFactory, OrganizationFactory, PersonFactory, SeatFactory
CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory, ImageFactory, VideoFactory,
OrganizationFactory, PersonFactory, SeatFactory, ProgramFactory
)
......@@ -358,3 +360,26 @@ class CourseRunSearchSerializerTests(TestCase):
'type': course_run.type,
}
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')
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/course_runs', views.CourseRunSearchViewSet, base_name='search-course_runs')
router.register(r'search/programs', views.ProgramSearchViewSet, base_name='search-programs')
urlpatterns += router.urls
......@@ -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.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.models import Course, CourseRun, Seat
from course_discovery.apps.course_metadata.models import Course, CourseRun, Seat, Program
logger = logging.getLogger(__name__)
User = get_user_model()
......@@ -360,14 +360,14 @@ class AffiliateWindowViewSet(viewsets.ViewSet):
return Response(serializer.data)
class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
document_uid_field = 'key'
facet_filter_backends = [HaystackFacetFilterWithQueries, HaystackFilter]
load_all = True
lookup_field = 'key'
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.
pagination_class = PageNumberPagination
......@@ -382,7 +382,7 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
type: string
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")
def facets(self, request):
......@@ -412,10 +412,10 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
pytype: str
required: false
"""
return super(BaseCourseHaystackViewSet, self).facets(request)
return super(BaseHaystackViewSet, self).facets(request)
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()
field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {})
......@@ -431,20 +431,27 @@ class BaseCourseHaystackViewSet(FacetMixin, HaystackViewSet):
return queryset
class CourseSearchViewSet(BaseCourseHaystackViewSet):
class CourseSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseFacetSerializer
index_models = (Course,)
serializer_class = serializers.CourseSearchSerializer
class CourseRunSearchViewSet(BaseCourseHaystackViewSet):
class CourseRunSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseRunFacetSerializer
index_models = (CourseRun,)
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 AggregateSearchViewSet(BaseCourseHaystackViewSet):
class ProgramSearchViewSet(BaseHaystackViewSet):
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. """
facet_serializer_class = serializers.AggregateFacetSearchSerializer
serializer_class = serializers.AggregateSearchSerializer
import datetime
import logging
from urllib.parse import urljoin
from uuid import uuid4
import pytz
from django.conf import settings
from django.db import models
from django.db.models.query_utils import Q
from django.utils.translation import ugettext_lazy as _
......@@ -425,5 +427,13 @@ class Program(TimeStampedModel):
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):
return self.name
from haystack import indexes
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):
......@@ -23,7 +32,7 @@ class BaseIndex(indexes.SearchIndex):
return self.model.objects.all()
class BaseCourseIndex(BaseIndex):
class BaseCourseIndex(OrganizationsMixin, BaseIndex):
key = indexes.CharField(model_attr='key', stored=True)
title = indexes.CharField(model_attr='title')
short_description = indexes.CharField(model_attr='short_description', null=True)
......@@ -31,10 +40,6 @@ class BaseCourseIndex(BaseIndex):
subjects = 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):
return [subject.name for subject in obj.subjects.all()]
......@@ -97,3 +102,17 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
def prepare_transcript_languages(self, obj):
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
import ddt
import mock
import pytz
from django.conf import settings
from django.test import TestCase
from course_discovery.apps.core.utils import SearchQuerySetWrapper
......@@ -243,6 +244,16 @@ class ProgramTests(TestCase):
def test_str(self):
"""Verify that a program is properly converted to a str."""
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