Commit cdf121f6 by Clinton Blackburn Committed by GitHub

Exposing additional course run fields via search API (#142)

ECOM-4747
parent 84401d0d
from rest_framework import pagination
class PageNumberPagination(pagination.PageNumberPagination):
page_size_query_param = 'page_size'
...@@ -29,16 +29,18 @@ COURSE_RUN_FACET_FIELD_OPTIONS = { ...@@ -29,16 +29,18 @@ COURSE_RUN_FACET_FIELD_OPTIONS = {
'transcript_languages': {}, 'transcript_languages': {},
'pacing_type': {}, 'pacing_type': {},
'start': { 'start': {
"start_date": datetime.datetime.now() - datetime.timedelta(days=365), 'start_date': datetime.datetime.now() - datetime.timedelta(days=365),
"end_date": datetime.datetime.now(), 'end_date': datetime.datetime.now(),
"gap_by": "month", 'gap_by': 'month',
"gap_amount": 1, 'gap_amount': 1,
}, },
'content_type': {}, 'content_type': {},
'type': {},
} }
COURSE_RUN_SEARCH_FIELDS = ( COURSE_RUN_SEARCH_FIELDS = (
'key', 'title', 'short_description', 'full_description', 'start', 'end', 'enrollment_start', 'enrollment_end', 'key', 'title', 'short_description', 'full_description', 'start', 'end', 'enrollment_start', 'enrollment_end',
'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'text', 'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org', 'number', 'seat_types',
'image_url', 'type', 'text',
) )
......
...@@ -3,22 +3,27 @@ from urllib.parse import urlencode ...@@ -3,22 +3,27 @@ from urllib.parse import urlencode
import ddt import ddt
from django.test import TestCase from django.test import TestCase
from haystack.query import SearchQuerySet
from opaque_keys.edx.keys import CourseKey
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.serializers import( 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)
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.tests.factories import ( from course_discovery.apps.course_metadata.tests.factories import (
CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory, CourseFactory, CourseRunFactory, SubjectFactory, PrerequisiteFactory,
ImageFactory, VideoFactory, OrganizationFactory, PersonFactory, SeatFactory ImageFactory, VideoFactory, OrganizationFactory, PersonFactory, SeatFactory
) )
# pylint:disable=no-member
def json_date_format(datetime_obj): def json_date_format(datetime_obj):
return datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ") return datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ")
...@@ -314,3 +319,42 @@ class AffiliateWindowSerializerTests(TestCase): ...@@ -314,3 +319,42 @@ class AffiliateWindowSerializerTests(TestCase):
'category': 'Other Experiences' 'category': 'Other Experiences'
} }
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
class CourseRunSearchSerializerTests(TestCase):
def serialize_datetime(self, d):
return d.strftime('%Y-%m-%dT%H:%M:%S') if d else None
def serialize_language(self, language):
return language.name
def test_data(self):
course_run = CourseRunFactory()
course_run_key = CourseKey.from_string(course_run.key)
# NOTE: This serializer expects SearchQuerySet results, so we run a search on the newly-created object
# to generate such a result.
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
serializer = CourseRunSearchSerializer(result)
expected = {
'transcript_languages': [self.serialize_language(l) for l in course_run.transcript_languages.all()],
'short_description': course_run.short_description,
'start': self.serialize_datetime(course_run.start),
'end': self.serialize_datetime(course_run.end),
'enrollment_start': self.serialize_datetime(course_run.enrollment_start),
'enrollment_end': self.serialize_datetime(course_run.enrollment_end),
'key': course_run.key,
'marketing_url': course_run.marketing_url,
'pacing_type': course_run.pacing_type,
'language': self.serialize_language(course_run.language),
'full_description': course_run.full_description,
'title': course_run.title,
'content_type': 'courserun',
'org': course_run_key.org,
'number': course_run_key.course,
'seat_types': course_run.seat_types,
'image_url': course_run.image_url,
'type': course_run.type,
}
self.assertDictEqual(serializer.data, expected)
...@@ -3,10 +3,13 @@ import urllib.parse ...@@ -3,10 +3,13 @@ import urllib.parse
import ddt import ddt
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
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
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.models import CourseRun
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory
...@@ -31,28 +34,9 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase): ...@@ -31,28 +34,9 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
url = '{path}?{qs}'.format(path=path, qs=qs) url = '{path}?{qs}'.format(path=path, qs=qs)
return self.client.get(url) return self.client.get(url)
def serialize_date(self, d):
return d.strftime('%Y-%m-%dT%H:%M:%S') if d else None
def serialize_language(self, language):
return language.name
def serialize_course_run(self, course_run): def serialize_course_run(self, course_run):
return { result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
'transcript_languages': [self.serialize_language(l) for l in course_run.transcript_languages.all()], return CourseRunSearchSerializer(result).data
'short_description': course_run.short_description,
'start': self.serialize_date(course_run.start),
'end': self.serialize_date(course_run.end),
'enrollment_start': self.serialize_date(course_run.enrollment_start),
'enrollment_end': self.serialize_date(course_run.enrollment_end),
'key': course_run.key,
'marketing_url': course_run.marketing_url,
'pacing_type': course_run.pacing_type,
'language': self.serialize_language(course_run.language),
'full_description': course_run.full_description,
'title': course_run.title,
'content_type': 'courserun'
}
@ddt.data(True, False) @ddt.data(True, False)
def test_authentication(self, faceted): def test_authentication(self, faceted):
......
...@@ -19,12 +19,12 @@ from edx_rest_framework_extensions.permissions import IsSuperuser ...@@ -19,12 +19,12 @@ from edx_rest_framework_extensions.permissions import IsSuperuser
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import detail_route, list_route from rest_framework.decorators import detail_route, list_route
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from course_discovery.apps.api import serializers from course_discovery.apps.api import serializers
from course_discovery.apps.api.filters import PermissionsFilter from course_discovery.apps.api.filters import PermissionsFilter
from course_discovery.apps.api.pagination import PageNumberPagination
from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer, CourseRunCSVRenderer from course_discovery.apps.api.renderers import AffiliateWindowXMLRenderer, CourseRunCSVRenderer
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
......
...@@ -275,6 +275,35 @@ class CourseRun(TimeStampedModel): ...@@ -275,6 +275,35 @@ class CourseRun(TimeStampedModel):
def organizations(self): def organizations(self):
return self.course.organizations return self.course.organizations
@property
def seat_types(self):
return list(self.seats.values_list('type', flat=True))
@property
def type(self):
seat_types = set(self.seat_types)
mapping = (
('credit', {'credit'}),
('professional', {'professional', 'no-id-professional'}),
('verified', {'verified'}),
('honor', {'honor'}),
('audit', {'audit'}),
)
for course_run_type, matching_seat_types in mapping:
if matching_seat_types & seat_types:
return course_run_type
logger.warning('Unable to determine type for course run [%s]. Seat types are [%s]', self.key, seat_types)
return None
@property
def image_url(self):
if self.image:
return self.image.src
return None
@classmethod @classmethod
def search(cls, query): def search(cls, query):
""" Queries the search index. """ Queries the search index.
......
...@@ -75,6 +75,9 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -75,6 +75,9 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
transcript_languages = indexes.MultiValueField(faceted=True) transcript_languages = indexes.MultiValueField(faceted=True)
pacing_type = indexes.CharField(model_attr='pacing_type', null=True, faceted=True) pacing_type = indexes.CharField(model_attr='pacing_type', null=True, faceted=True)
marketing_url = indexes.CharField(model_attr='marketing_url', null=True) marketing_url = indexes.CharField(model_attr='marketing_url', null=True)
seat_types = indexes.MultiValueField(model_attr='seat_types', null=True, faceted=True)
type = indexes.CharField(model_attr='type', null=True, faceted=True)
image_url = indexes.CharField(model_attr='image_url', null=True)
def _prepare_language(self, language): def _prepare_language(self, language):
return language.name return language.name
......
import datetime import datetime
import ddt import ddt
import mock
import pytz import pytz
from django.test import TestCase from django.test import TestCase
...@@ -11,6 +12,8 @@ from course_discovery.apps.course_metadata.models import ( ...@@ -11,6 +12,8 @@ from course_discovery.apps.course_metadata.models import (
from course_discovery.apps.course_metadata.tests import factories from course_discovery.apps.course_metadata.tests import factories
# pylint: disable=no-member
class CourseTests(TestCase): class CourseTests(TestCase):
""" Tests for the `Course` model. """ """ Tests for the `Course` model. """
...@@ -36,19 +39,18 @@ class CourseTests(TestCase): ...@@ -36,19 +39,18 @@ class CourseTests(TestCase):
def test_owners(self): def test_owners(self):
""" Verify that the owners property returns only owner related organizations. """ """ Verify that the owners property returns only owner related organizations. """
owners = self.course.owners # pylint: disable=no-member owners = self.course.owners
self.assertEqual(len(owners), 1) self.assertEqual(len(owners), 1)
self.assertEqual(owners[0], self.owner) self.assertEqual(owners[0], self.owner)
def test_sponsors(self): def test_sponsors(self):
""" Verify that the sponsors property returns only sponsor related organizations. """ """ Verify that the sponsors property returns only sponsor related organizations. """
sponsors = self.course.sponsors # pylint: disable=no-member sponsors = self.course.sponsors
self.assertEqual(len(sponsors), 1) self.assertEqual(len(sponsors), 1)
self.assertEqual(sponsors[0], self.sponsor) self.assertEqual(sponsors[0], self.sponsor)
def test_active_course_runs(self): def test_active_course_runs(self):
""" Verify the property returns only course runs currently open for enrollment or opening in the future. """ """ Verify the property returns only course runs currently open for enrollment or opening in the future. """
# pylint: disable=no-member
self.assertListEqual(list(self.course.active_course_runs), []) self.assertListEqual(list(self.course.active_course_runs), [])
# Create course with end date in future and enrollment_end in past. # Create course with end date in future and enrollment_end in past.
...@@ -93,7 +95,6 @@ class CourseRunTests(TestCase): ...@@ -93,7 +95,6 @@ class CourseRunTests(TestCase):
def test_str(self): def test_str(self):
""" Verify casting an instance to a string returns a string containing the key and title. """ """ Verify casting an instance to a string returns a string containing the key and title. """
course_run = self.course_run course_run = self.course_run
# pylint: disable=no-member
self.assertEqual(str(course_run), '{key}: {title}'.format(key=course_run.key, title=course_run.title)) self.assertEqual(str(course_run), '{key}: {title}'.format(key=course_run.key, title=course_run.title))
@ddt.data('title', 'short_description', 'full_description') @ddt.data('title', 'short_description', 'full_description')
...@@ -125,6 +126,56 @@ class CourseRunTests(TestCase): ...@@ -125,6 +126,56 @@ class CourseRunTests(TestCase):
expected_sorted = sorted(course_runs, key=lambda course_run: course_run.key) expected_sorted = sorted(course_runs, key=lambda course_run: course_run.key)
self.assertEqual(actual_sorted, expected_sorted) self.assertEqual(actual_sorted, expected_sorted)
def test_seat_types(self):
""" Verify the property returns a list of all seat types associated with the course run. """
self.assertEqual(self.course_run.seat_types, [])
seats = factories.SeatFactory.create_batch(3, course_run=self.course_run)
expected = sorted([seat.type for seat in seats])
self.assertEqual(sorted(self.course_run.seat_types), expected)
def test_image_url(self):
""" Verify the property returns the associated image's URL. """
self.assertEqual(self.course_run.image_url, self.course_run.image.src)
self.course_run.image = None
self.assertIsNone(self.course_run.image)
self.assertIsNone(self.course_run.image_url)
@ddt.data(
('obviously-wrong', None,),
(('audit',), 'audit',),
(('honor',), 'honor',),
(('credit', 'verified', 'audit',), 'credit',),
(('verified', 'honor',), 'verified',),
(('professional',), 'professional',),
(('no-id-professional',), 'professional',),
)
@ddt.unpack
def test_type(self, seat_types, expected_course_run_type):
""" Verify the property returns the appropriate type string for the CourseRun. """
for seat_type in seat_types:
factories.SeatFactory(course_run=self.course_run, type=seat_type)
self.assertEqual(self.course_run.type, expected_course_run_type)
def assert_course_run_has_no_type(self, course_run, expected_seats):
""" Asserts the given CourseRun has no type value, and a message is logged to that effect. """
with mock.patch('course_discovery.apps.course_metadata.models.logger') as mock_logger:
self.assertEqual(course_run.type, None)
mock_logger.warning.assert_called_with(
'Unable to determine type for course run [%s]. Seat types are [%s]',
course_run.key,
expected_seats
)
def test_type_with_unknown_seat_type(self):
""" Verify the property logs a warning if the CourseRun has no Seats or the Seats have an unknown seat type. """
self.assert_course_run_has_no_type(self.course_run, set())
seat_type = 'super-wrong'
factories.SeatFactory(course_run=self.course_run, type=seat_type)
self.assert_course_run_has_no_type(self.course_run, set([seat_type]))
class OrganizationTests(TestCase): class OrganizationTests(TestCase):
""" Tests for the `Organization` model. """ """ Tests for the `Organization` model. """
......
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