Commit 9a33d0bf by Uman Shahzad Committed by Uman Shahzad

Add full detail search endpoints.

* Ignore all types of coverage files.

  I was getting things like `.coverage.$user`.

* Refactor existing serializers.

  Some serializers had their metadata all the way at the top
  of the file in separate variables, even though no other
  serializer were using those variables.

* Put things into proper places to make it easier to read
  and change, without having to jump up and down a 1000+
  line file.

* Add new serializers and use them in viewsets.

  These are the serializers for all 4 new endpoints,
  for courses, course runs, programs, and their aggregation.

* Add new mixin for detail endpoint.

  The mixin will add `/detail` to the end of whatever viewset
  that uses it.

* Call isort on the codebase.

* Refactor serializer test code.

  This will make it x10 easier to add tests for the
  serializers we introduced in this PR.

* Add test code for serializers.

* Refactor and add tests for viewsets.
parent 31ac8f5c
......@@ -33,6 +33,7 @@ assets/
# Unit test / coverage reports
.coverage
.coverage.*
htmlcov
.tox
nosetests.xml
......
from rest_framework import status
from rest_framework.exceptions import APIException
class InvalidPartnerError(APIException):
status_code = status.HTTP_400_BAD_REQUEST
"""
Mixins for the API application.
"""
# pylint: disable=not-callable
from rest_framework.decorators import list_route
from rest_framework.response import Response
class DetailMixin(object):
"""Mixin for adding in a detail endpoint using a special detail serializer."""
detail_serializer_class = None
@list_route(methods=['get'])
def details(self, request): # pylint: disable=unused-argument
"""
List detailed results.
---
parameters:
- name: q
description: Search text
paramType: query
type: string
required: false
"""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_detail_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_detail_serializer(queryset, many=True)
return Response(serializer.data)
def get_detail_serializer(self, *args, **kwargs):
"""
Return the serializer instance that should be used for validating and
deserializing input, and for serializing output.
"""
serializer_class = self.get_detail_serializer_class()
kwargs['context'] = self.get_serializer_context()
return serializer_class(*args, **kwargs)
def get_detail_serializer_class(self):
"""
Return the class to use for the serializer.
Defaults to using `self.detail_serializer_class`.
"""
assert self.detail_serializer_class is not None, (
"'%s' should either include a `detail_serializer_class` attribute, "
"or override the `get_detail_serializer_class()` method."
% self.__class__.__name__
)
return self.detail_serializer_class
......@@ -10,7 +10,7 @@ from django.contrib.auth import get_user_model
from django.db.models.query import Prefetch
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _
from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer
from drf_haystack.serializers import HaystackFacetSerializer, HaystackSerializer, HaystackSerializerMixin
from rest_framework import serializers
from rest_framework.fields import DictField
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
......@@ -18,68 +18,18 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie
from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.core.api_client.lms import LMSAPIClient
from course_discovery.apps.course_metadata import search_indexes
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.models import (
FAQ, CorporateEndorsement, Course, CourseEntitlement, CourseRun, Endorsement, Image, Organization, Person,
PersonSocialNetwork, PersonWork, Position, Prerequisite, Program, ProgramType, Seat, SeatType, Subject, Topic,
Video
)
from course_discovery.apps.course_metadata.search_indexes import CourseIndex, CourseRunIndex, ProgramIndex
User = get_user_model()
COMMON_IGNORED_FIELDS = ('text',)
COMMON_SEARCH_FIELD_ALIASES = {
'q': 'text',
}
COURSE_RUN_FACET_FIELD_OPTIONS = {
'level_type': {},
'organizations': {'size': settings.SEARCH_FACET_LIMIT},
'prerequisites': {},
'subjects': {},
'language': {},
'transcript_languages': {},
'pacing_type': {},
'content_type': {},
'type': {},
'seat_types': {},
'mobile_available': {},
}
COURSE_RUN_FACET_FIELD_QUERIES = {
'availability_current': {'query': 'start:<now AND end:>now'},
'availability_starting_soon': {'query': 'start:[now TO now+60d]'},
'availability_upcoming': {'query': 'start:[now+60d TO *]'},
'availability_archived': {'query': 'end:<=now'},
}
COURSE_RUN_SEARCH_FIELDS = (
'text', 'key', 'title', 'short_description', 'full_description', 'start', 'end', 'enrollment_start',
'enrollment_end', 'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org',
'number', 'seat_types', 'image_url', 'type', 'level_type', 'availability', 'published', 'partner', 'program_types',
'authoring_organization_uuids', 'subject_uuids', 'staff_uuids', 'mobile_available', 'logo_image_urls',
'aggregation_key', 'min_effort', 'max_effort', 'weeks_to_complete', 'has_enrollable_seats',
'first_enrollable_paid_seat_sku'
)
PROGRAM_FACET_FIELD_OPTIONS = {
'status': {},
'type': {},
'seat_types': {},
}
BASE_PROGRAM_FIELDS = (
'text', 'uuid', 'title', 'subtitle', 'type', 'marketing_url', 'content_type', 'status', 'card_image_url',
'published', 'partner', 'language',
)
PROGRAM_SEARCH_FIELDS = BASE_PROGRAM_FIELDS + (
'aggregation_key', 'authoring_organizations', 'authoring_organization_uuids', 'subject_uuids', 'staff_uuids',
'weeks_to_complete_min', 'weeks_to_complete_max', 'min_hours_effort_per_week', 'max_hours_effort_per_week',
'hidden', 'is_program_eligible_for_one_click_purchase',
)
PROGRAM_FACET_FIELDS = BASE_PROGRAM_FIELDS + ('organizations',)
COMMON_SEARCH_FIELD_ALIASES = {'q': 'text'}
PREFETCH_FIELDS = {
'course_run': [
'course__level_type',
......@@ -178,6 +128,17 @@ class TimestampModelSerializer(serializers.ModelSerializer):
modified = serializers.DateTimeField()
class ContentTypeSerializer(serializers.Serializer):
"""Serializer for retrieving the type of content. Useful in views returning multiple serialized models."""
content_type = serializers.SerializerMethodField()
def get_content_type(self, obj):
return obj._meta.model_name
class Meta(object):
fields = ('content_type',)
class NamedModelSerializer(serializers.ModelSerializer):
"""Serializer for models inheriting from ``AbstractNamedModel``."""
name = serializers.CharField()
......@@ -904,7 +865,7 @@ class ProgramSerializer(MinimalProgramSerializer):
"""
Prefetch the related objects that will be serialized with a `Program`.
We use Pefetch objects so that we can prefetch and select all the way down the
We use Prefetch objects so that we can prefetch and select all the way down the
chain of related fields from programs to course runs (i.e., we want control over
the querysets that we're prefetching).
"""
......@@ -1155,6 +1116,7 @@ class QueryFacetFieldSerializer(serializers.Serializer):
class BaseHaystackFacetSerializer(HaystackFacetSerializer):
_abstract = True
serialize_objects = True
def get_fields(self):
query_facet_counts = self.instance.pop('queries', {})
......@@ -1186,27 +1148,28 @@ class BaseHaystackFacetSerializer(HaystackFacetSerializer):
class CourseSearchSerializer(HaystackSerializer):
content_type = serializers.CharField(source='model_name')
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = ('key', 'title', 'short_description', 'full_description', 'text', 'aggregation_key',)
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [CourseIndex]
index_classes = [search_indexes.CourseIndex]
fields = search_indexes.BASE_SEARCH_INDEX_FIELDS + (
'full_description',
'key',
'short_description',
'title',
)
class CourseFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
ignore_fields = COMMON_IGNORED_FIELDS
field_options = {
'level_type': {},
'organizations': {},
'prerequisites': {},
'subjects': {},
}
ignore_fields = COMMON_IGNORED_FIELDS
class CourseRunSearchSerializer(HaystackSerializer):
......@@ -1217,19 +1180,68 @@ class CourseRunSearchSerializer(HaystackSerializer):
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = COURSE_RUN_SEARCH_FIELDS
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [CourseRunIndex]
index_classes = [search_indexes.CourseRunIndex]
fields = search_indexes.BASE_SEARCH_INDEX_FIELDS + (
'authoring_organization_uuids',
'availability',
'end',
'enrollment_end',
'enrollment_start',
'first_enrollable_paid_seat_sku',
'full_description',
'has_enrollable_seats',
'image_url',
'key',
'language',
'level_type',
'logo_image_urls',
'marketing_url',
'max_effort',
'min_effort',
'mobile_available',
'number',
'org',
'pacing_type',
'partner',
'program_types',
'published',
'seat_types',
'short_description',
'staff_uuids',
'start',
'subject_uuids',
'text',
'title',
'transcript_languages',
'type',
'weeks_to_complete'
)
class CourseRunFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
field_options = COURSE_RUN_FACET_FIELD_OPTIONS
field_queries = COURSE_RUN_FACET_FIELD_QUERIES
ignore_fields = COMMON_IGNORED_FIELDS
field_options = {
'content_type': {},
'language': {},
'level_type': {},
'mobile_available': {},
'organizations': {'size': settings.SEARCH_FACET_LIMIT},
'pacing_type': {},
'prerequisites': {},
'seat_types': {},
'subjects': {},
'transcript_languages': {},
'type': {},
}
field_queries = {
'availability_current': {'query': 'start:<now AND end:>now'},
'availability_starting_soon': {'query': 'start:[now TO now+60d]'},
'availability_upcoming': {'query': 'start:[now+60d TO *]'},
'availability_archived': {'query': 'end:<=now'},
}
class ProgramSearchSerializer(HaystackSerializer):
......@@ -1241,32 +1253,86 @@ class ProgramSearchSerializer(HaystackSerializer):
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
field_options = PROGRAM_FACET_FIELD_OPTIONS
fields = PROGRAM_SEARCH_FIELDS
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [ProgramIndex]
index_classes = [search_indexes.ProgramIndex]
fields = search_indexes.BASE_SEARCH_INDEX_FIELDS + search_indexes.BASE_PROGRAM_FIELDS + (
'authoring_organization_uuids',
'authoring_organizations',
'hidden',
'is_program_eligible_for_one_click_purchase',
'max_hours_effort_per_week',
'min_hours_effort_per_week',
'staff_uuids',
'subject_uuids',
'weeks_to_complete_max',
'weeks_to_complete_min',
)
class ProgramFacetSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
field_options = PROGRAM_FACET_FIELD_OPTIONS
fields = PROGRAM_FACET_FIELDS
ignore_fields = COMMON_IGNORED_FIELDS
index_classes = [ProgramIndex]
index_classes = [search_indexes.ProgramIndex]
field_options = {
'status': {},
'type': {},
'seat_types': {},
}
fields = search_indexes.BASE_PROGRAM_FIELDS + (
'organizations',
)
class AggregateSearchSerializer(HaystackSerializer):
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
fields = COURSE_RUN_SEARCH_FIELDS + PROGRAM_SEARCH_FIELDS
ignore_fields = COMMON_IGNORED_FIELDS
fields = CourseRunSearchSerializer.Meta.fields + ProgramSearchSerializer.Meta.fields
serializers = {
search_indexes.CourseRunIndex: CourseRunSearchSerializer,
search_indexes.CourseIndex: CourseSearchSerializer,
search_indexes.ProgramIndex: ProgramSearchSerializer,
}
class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
ignore_fields = COMMON_IGNORED_FIELDS
field_queries = CourseRunFacetSerializer.Meta.field_queries
field_options = {
**CourseRunFacetSerializer.Meta.field_options,
**ProgramFacetSerializer.Meta.field_options
}
serializers = {
search_indexes.CourseRunIndex: CourseRunFacetSerializer,
search_indexes.CourseIndex: CourseFacetSerializer,
search_indexes.ProgramIndex: ProgramFacetSerializer,
}
class CourseSearchModelSerializer(HaystackSerializerMixin, ContentTypeSerializer, CourseWithProgramsSerializer):
class Meta(CourseWithProgramsSerializer.Meta):
fields = ContentTypeSerializer.Meta.fields + CourseWithProgramsSerializer.Meta.fields
class CourseRunSearchModelSerializer(HaystackSerializerMixin, ContentTypeSerializer, CourseRunWithProgramsSerializer):
class Meta(CourseRunWithProgramsSerializer.Meta):
fields = ContentTypeSerializer.Meta.fields + CourseRunWithProgramsSerializer.Meta.fields
class ProgramSearchModelSerializer(HaystackSerializerMixin, ContentTypeSerializer, ProgramSerializer):
class Meta(ProgramSerializer.Meta):
fields = ContentTypeSerializer.Meta.fields + ProgramSerializer.Meta.fields
class AggregateSearchModelSerializer(HaystackSerializer):
class Meta:
serializers = {
CourseRunIndex: CourseRunSearchSerializer,
CourseIndex: CourseSearchSerializer,
ProgramIndex: ProgramSearchSerializer,
search_indexes.CourseRunIndex: CourseRunSearchModelSerializer,
search_indexes.CourseIndex: CourseSearchModelSerializer,
search_indexes.ProgramIndex: ProgramSearchModelSerializer,
}
......@@ -1294,21 +1360,6 @@ class TypeaheadSearchSerializer(serializers.Serializer):
programs = TypeaheadProgramSearchSerializer(many=True)
class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
serialize_objects = True
class Meta:
field_aliases = COMMON_SEARCH_FIELD_ALIASES
field_options = {**COURSE_RUN_FACET_FIELD_OPTIONS, **PROGRAM_FACET_FIELD_OPTIONS}
field_queries = COURSE_RUN_FACET_FIELD_QUERIES
ignore_fields = COMMON_IGNORED_FIELDS
serializers = {
CourseRunIndex: CourseRunFacetSerializer,
CourseIndex: CourseFacetSerializer,
ProgramIndex: ProgramFacetSerializer,
}
class TopicSerializer(serializers.ModelSerializer):
"""Serializer for the ``Topic`` model."""
......
# pylint: disable=no-member, test-inherits-tests
# pylint: disable=no-member,test-inherits-tests
import datetime
import itertools
from urllib.parse import urlencode
......@@ -19,14 +19,15 @@ from waffle.testutils import override_switch
from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
from course_discovery.apps.api.serializers import (
AffiliateWindowSerializer, CatalogSerializer, ContainedCourseRunsSerializer, ContainedCoursesSerializer,
CorporateEndorsementSerializer, CourseEntitlementSerializer, CourseRunSearchSerializer, CourseRunSerializer,
CourseRunWithProgramsSerializer, CourseSearchSerializer, CourseSerializer, CourseWithProgramsSerializer,
EndorsementSerializer, FAQSerializer, FlattenedCourseRunWithCourseSerializer, ImageSerializer,
MinimalCourseRunSerializer, MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalProgramCourseSerializer,
MinimalProgramSerializer, NestedProgramSerializer, OrganizationSerializer, PersonSerializer, PositionSerializer,
PrerequisiteSerializer, ProgramSearchSerializer, ProgramSerializer, ProgramTypeSerializer, SeatSerializer,
SubjectSerializer, TopicSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer,
VideoSerializer, get_utm_source_for_user
ContentTypeSerializer, CorporateEndorsementSerializer, CourseEntitlementSerializer, CourseRunSearchModelSerializer,
CourseRunSearchSerializer, CourseRunSerializer, CourseRunWithProgramsSerializer, CourseSearchModelSerializer,
CourseSearchSerializer, CourseSerializer, CourseWithProgramsSerializer, EndorsementSerializer, FAQSerializer,
FlattenedCourseRunWithCourseSerializer, ImageSerializer, MinimalCourseRunSerializer, MinimalCourseSerializer,
MinimalOrganizationSerializer, MinimalProgramCourseSerializer, MinimalProgramSerializer, NestedProgramSerializer,
OrganizationSerializer, PersonSerializer, PositionSerializer, PrerequisiteSerializer, ProgramSearchModelSerializer,
ProgramSearchSerializer, ProgramSerializer, ProgramTypeSerializer, SeatSerializer, SubjectSerializer,
TopicSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer, VideoSerializer,
get_utm_source_for_user
)
from course_discovery.apps.api.tests.mixins import SiteMixin
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
......@@ -109,7 +110,8 @@ class CatalogSerializerTests(ElasticsearchTestMixin, TestCase):
class MinimalCourseSerializerTests(SiteMixin, TestCase):
serializer_class = MinimalCourseSerializer
def get_expected_data(self, course, request):
@classmethod
def get_expected_data(cls, course, request):
context = {'request': request}
return {
......@@ -136,7 +138,8 @@ class MinimalCourseSerializerTests(SiteMixin, TestCase):
class CourseSerializerTests(MinimalCourseSerializerTests):
serializer_class = CourseSerializer
def get_expected_data(self, course, request):
@classmethod
def get_expected_data(cls, course, request):
expected = super().get_expected_data(course, request)
expected.update({
'short_description': course.short_description,
......@@ -179,7 +182,8 @@ class CourseSerializerTests(MinimalCourseSerializerTests):
class CourseWithProgramsSerializerTests(CourseSerializerTests):
serializer_class = CourseWithProgramsSerializer
def get_expected_data(self, course, request):
@classmethod
def get_expected_data(cls, course, request):
expected = super().get_expected_data(course, request)
expected.update({
'programs': NestedProgramSerializer(
......@@ -224,7 +228,8 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests):
class MinimalCourseRunSerializerTests(TestCase):
serializer_class = MinimalCourseRunSerializer
def get_expected_data(self, course_run, request): # pylint: disable=unused-argument
@classmethod
def get_expected_data(cls, course_run, request): # pylint: disable=unused-argument
return {
'key': course_run.key,
'uuid': str(course_run.uuid),
......@@ -259,7 +264,8 @@ class MinimalCourseRunSerializerTests(TestCase):
class CourseRunSerializerTests(MinimalCourseRunSerializerTests):
serializer_class = CourseRunSerializer
def get_expected_data(self, course_run, request):
@classmethod
def get_expected_data(cls, course_run, request):
expected = super().get_expected_data(course_run, request)
expected.update({
'course': course_run.course.key,
......@@ -308,17 +314,7 @@ class CourseRunWithProgramsSerializerTests(TestCase):
def test_data(self):
serializer = CourseRunWithProgramsSerializer(self.course_run, context=self.serializer_context)
ProgramFactory(courses=[self.course_run.course])
expected = CourseRunSerializer(self.course_run, context=self.serializer_context).data
expected.update({
'programs': NestedProgramSerializer(
self.course_run.course.programs,
many=True,
context=self.serializer_context
).data,
})
self.assertDictEqual(serializer.data, expected)
self.assertDictEqual(serializer.data, self.get_expected_data(self.course_run, self.request))
def test_data_excluded_course_run(self):
"""
......@@ -328,11 +324,8 @@ class CourseRunWithProgramsSerializerTests(TestCase):
serializer = CourseRunWithProgramsSerializer(self.course_run, context=self.serializer_context)
ProgramFactory(courses=[self.course_run.course], excluded_course_runs=[self.course_run])
expected = CourseRunSerializer(self.course_run, context=self.serializer_context).data
expected.update({
'programs': [],
})
self.assertDictEqual(serializer.data, expected)
expected.update({'programs': []})
assert serializer.data == expected
def test_exclude_deleted_programs(self):
"""
......@@ -398,9 +391,22 @@ class CourseRunWithProgramsSerializerTests(TestCase):
NestedProgramSerializer([retired_program], many=True, context=self.serializer_context).data
)
@classmethod
def get_expected_data(cls, course_run, request):
expected = CourseRunSerializer(course_run, context={'request': request}).data
expected.update({
'programs': NestedProgramSerializer(
course_run.course.programs,
many=True,
context={'request': request},
).data,
})
return expected
class FlattenedCourseRunWithCourseSerializerTests(TestCase): # pragma: no cover
def serialize_seats(self, course_run):
@classmethod
def serialize_seats(cls, course_run):
seats = {
'audit': {
'type': ''
......@@ -442,21 +448,23 @@ class FlattenedCourseRunWithCourseSerializerTests(TestCase): # pragma: no cover
return seats
def serialize_items(self, organizations, attr):
@classmethod
def serialize_items(cls, organizations, attr):
return ','.join([getattr(organization, attr) for organization in organizations])
def get_expected_data(self, request, course_run):
@classmethod
def get_expected_data(cls, request, course_run):
course = course_run.course
serializer_context = {'request': request}
expected = dict(CourseRunSerializer(course_run, context=serializer_context).data)
expected.update({
'subjects': self.serialize_items(course.subjects.all(), 'name'),
'seats': self.serialize_seats(course_run),
'owners': self.serialize_items(course.authoring_organizations.all(), 'key'),
'sponsors': self.serialize_items(course.sponsoring_organizations.all(), 'key'),
'prerequisites': self.serialize_items(course.prerequisites.all(), 'name'),
'subjects': cls.serialize_items(course.subjects.all(), 'name'),
'seats': cls.serialize_seats(course_run),
'owners': cls.serialize_items(course.authoring_organizations.all(), 'key'),
'sponsors': cls.serialize_items(course.sponsoring_organizations.all(), 'key'),
'prerequisites': cls.serialize_items(course.prerequisites.all(), 'name'),
'level_type': course_run.level_type.name if course_run.level_type else None,
'expected_learning_items': self.serialize_items(course.expected_learning_items.all(), 'value'),
'expected_learning_items': cls.serialize_items(course.expected_learning_items.all(), 'value'),
'course_key': course.key,
'image': ImageField().to_representation(course_run.card_image_url),
})
......@@ -623,7 +631,8 @@ class MinimalProgramSerializerTests(TestCase):
order_courses_by_start_date=False,
)
def get_expected_data(self, program, request):
@classmethod
def get_expected_data(cls, program, request):
image_field = StdImageSerializerField()
image_field._context = {'request': request} # pylint: disable=protected-access
......@@ -661,9 +670,9 @@ class MinimalProgramSerializerTests(TestCase):
class ProgramSerializerTests(MinimalProgramSerializerTests):
serializer_class = ProgramSerializer
def get_expected_data(self, program, request):
@classmethod
def get_expected_data(cls, program, request):
expected = super().get_expected_data(program, request)
expected.update({
'authoring_organizations': OrganizationSerializer(program.authoring_organizations, many=True).data,
'video': VideoSerializer(program.video).data,
......@@ -698,7 +707,6 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
'subjects': SubjectSerializer(program.subjects, many=True).data,
'transcript_languages': [serialize_language_to_code(l) for l in program.transcript_languages],
})
return expected
def test_data_with_exclusions(self):
......@@ -900,7 +908,8 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
class ProgramTypeSerializerTests(TestCase):
serializer_class = ProgramTypeSerializer
def get_expected_data(self, program_type, request):
@classmethod
def get_expected_data(cls, program_type, request):
image_field = StdImageSerializerField()
image_field._context = {'request': request} # pylint: disable=protected-access
......@@ -945,6 +954,23 @@ class ContainedCoursesSerializerTests(TestCase):
@ddt.ddt
class ContentTypeSerializerTests(TestCase):
@ddt.data(
(CourseFactory, 'course'),
(CourseRunFactory, 'courserun'),
(ProgramFactory, 'program'),
)
@ddt.unpack
def test_data(self, factory_class, expected_content_type):
obj = factory_class()
serializer = ContentTypeSerializer(obj)
expected = {
'content_type': expected_content_type
}
assert serializer.data == expected
@ddt.ddt
class NamedModelSerializerTests(TestCase):
@ddt.data(
(PrerequisiteFactory, PrerequisiteSerializer),
......@@ -1067,7 +1093,8 @@ class MinimalOrganizationSerializerTests(TestCase):
def create_organization(self):
return OrganizationFactory()
def get_expected_data(self, organization):
@classmethod
def get_expected_data(cls, organization):
return {
'uuid': str(organization.uuid),
'key': organization.key,
......@@ -1090,14 +1117,15 @@ class OrganizationSerializerTests(MinimalOrganizationSerializerTests):
organization.tags.add(self.TAG)
return organization
def get_expected_data(self, organization):
@classmethod
def get_expected_data(cls, organization):
expected = super().get_expected_data(organization)
expected.update({
'certificate_logo_image_url': organization.certificate_logo_image_url,
'description': organization.description,
'homepage_url': organization.homepage_url,
'logo_image_url': organization.logo_image_url,
'tags': [self.TAG],
'tags': [cls.TAG],
'marketing_url': organization.marketing_url,
})
......@@ -1215,11 +1243,23 @@ class AffiliateWindowSerializerTests(TestCase):
class CourseSearchSerializerTests(TestCase):
serializer_class = CourseSearchSerializer
def test_data(self):
request = make_request()
course = CourseFactory()
serializer = self.serialize_course(course)
serializer = self.serialize_course(course, request)
assert serializer.data == self.get_expected_data(course, request)
expected = {
def serialize_course(self, course, request):
""" Serializes the given `Course` as a search result. """
result = SearchQuerySet().models(Course).filter(key=course.key)[0]
serializer = self.serializer_class(result, context={'request': request})
return serializer
@classmethod
def get_expected_data(cls, course, request): # pylint: disable=unused-argument
return {
'key': course.key,
'title': course.title,
'short_description': course.short_description,
......@@ -1227,26 +1267,47 @@ class CourseSearchSerializerTests(TestCase):
'content_type': 'course',
'aggregation_key': 'course:{}'.format(course.key),
}
assert serializer.data == expected
def serialize_course(self, course):
""" Serializes the given `Course` as a search result. """
result = SearchQuerySet().models(Course).filter(key=course.key)[0]
serializer = CourseSearchSerializer(result)
return serializer
class CourseSearchModelSerializerTests(CourseSearchSerializerTests):
serializer_class = CourseSearchModelSerializer
@classmethod
def get_expected_data(cls, course, request):
expected_data = CourseWithProgramsSerializerTests.get_expected_data(course, request)
expected_data.update({'content_type': 'course'})
return expected_data
class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase):
serializer_class = CourseRunSearchSerializer
def test_data(self):
request = make_request()
course_run = CourseRunFactory(transcript_languages=LanguageTag.objects.filter(code__in=['en-us', 'zh-cn']),
authoring_organizations=[OrganizationFactory()])
SeatFactory.create(course_run=course_run, type='verified', price=10, sku='ABCDEF')
program = ProgramFactory(courses=[course_run.course])
self.reindex_courses(program)
serializer = self.serialize_course_run(course_run)
course_run_key = CourseKey.from_string(course_run.key)
orgs = course_run.authoring_organizations.all()
expected = {
serializer = self.serialize_course_run(course_run, request)
assert serializer.data == self.get_expected_data(course_run, request)
def test_data_without_serializers(self):
""" Verify a null `LevelType` is properly serialized as None. """
request = make_request()
course_run = CourseRunFactory(course__level_type=None)
serializer = self.serialize_course_run(course_run, request)
assert serializer.data['level_type'] is None
def serialize_course_run(self, course_run, request):
""" Serializes the given `CourseRun` as a search result. """
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
serializer = self.serializer_class(result, context={'request': request})
return serializer
@classmethod
def get_expected_data(cls, course_run, request): # pylint: disable=unused-argument
return {
'transcript_languages': [serialize_language(l) for l in course_run.transcript_languages.all()],
'min_effort': course_run.min_effort,
'max_effort': course_run.max_effort,
......@@ -1264,8 +1325,8 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase):
'full_description': course_run.full_description,
'title': course_run.title,
'content_type': 'courserun',
'org': course_run_key.org,
'number': course_run_key.course,
'org': CourseKey.from_string(course_run.key).org,
'number': CourseKey.from_string(course_run.key).course,
'seat_types': course_run.seat_types,
'image_url': course_run.image_url,
'type': course_run.type,
......@@ -1274,7 +1335,7 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase):
'published': course_run.status == CourseRunStatus.Published,
'partner': course_run.course.partner.short_code,
'program_types': course_run.program_types,
'logo_image_urls': [org.logo_image_url for org in orgs],
'logo_image_urls': [org.logo_image_url for org in course_run.authoring_organizations.all()],
'authoring_organization_uuids': get_uuids(course_run.authoring_organizations.all()),
'subject_uuids': get_uuids(course_run.subjects.all()),
'staff_uuids': get_uuids(course_run.staff.all()),
......@@ -1282,25 +1343,30 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase):
'has_enrollable_seats': course_run.has_enrollable_seats,
'first_enrollable_paid_seat_sku': course_run.first_enrollable_paid_seat_sku(),
}
assert serializer.data == expected
def serialize_course_run(self, course_run):
""" Serializes the given `CourseRun` as a search result. """
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
serializer = CourseRunSearchSerializer(result)
return serializer
def test_data_without_serializers(self):
""" Verify a null `LevelType` is properly serialized as None. """
course_run = CourseRunFactory(course__level_type=None)
serializer = self.serialize_course_run(course_run)
assert serializer.data['level_type'] is None
class CourseRunSearchModelSerializerTests(CourseRunSearchSerializerTests):
serializer_class = CourseRunSearchModelSerializer
@classmethod
def get_expected_data(cls, course_run, request):
expected_data = CourseRunWithProgramsSerializerTests.get_expected_data(course_run, request)
expected_data.update({'content_type': 'courserun'})
# This explicit conversion needs to happen, apparently because the real type is DRF's 'ReturnDict'. It's weird.
return dict(expected_data)
@pytest.mark.django_db
@pytest.mark.usefixtures('haystack_default_connection')
class TestProgramSearchSerializer:
def _create_expected_data(self, program):
class TestProgramSearchSerializer(TestCase):
serializer_class = ProgramSearchSerializer
def setUp(self):
super().setUp()
self.request = make_request()
@classmethod
def get_expected_data(cls, program, request): # pylint: disable=unused-argument
return {
'uuid': str(program.uuid),
'title': program.title,
......@@ -1330,81 +1396,89 @@ class TestProgramSearchSerializer:
'is_program_eligible_for_one_click_purchase': program.is_program_eligible_for_one_click_purchase
}
def serialize_program(self, program, request):
""" Serializes the given `Program` as a search result. """
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
serializer = self.serializer_class(result, context={'request': request})
return serializer
def test_data(self):
authoring_organization, crediting_organization = OrganizationFactory.create_batch(2)
program = ProgramFactory(authoring_organizations=[authoring_organization],
credit_backing_organizations=[crediting_organization])
# 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 = self._create_expected_data(program)
serializer = self.serialize_program(program, self.request)
expected = self.get_expected_data(program, self.request)
assert serializer.data == expected
def test_data_without_organizations(self):
""" Verify the serializer serialized programs with no associated organizations.
In such cases the organizations value should be an empty array. """
program = ProgramFactory(authoring_organizations=[], credit_backing_organizations=[])
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
serializer = ProgramSearchSerializer(result)
expected = self._create_expected_data(program)
serializer = self.serialize_program(program, self.request)
expected = self.get_expected_data(program, self.request)
assert serializer.data == expected
def test_data_with_languages(self):
"""
Verify that program languages are serialized.
"""
course_run = CourseRunFactory(
language=LanguageTag.objects.get(code='en-us'),
authoring_organizations=[OrganizationFactory()]
)
CourseRunFactory(
course=course_run.course,
language=LanguageTag.objects.get(code='zh-cmn')
)
course_run = CourseRunFactory(language=LanguageTag.objects.get(code='en-us'),
authoring_organizations=[OrganizationFactory()])
CourseRunFactory(course=course_run.course, language=LanguageTag.objects.get(code='zh-cmn'))
program = ProgramFactory(courses=[course_run.course])
serializer = self.serialize_program(program, self.request)
expected = self.get_expected_data(program, self.request)
assert serializer.data == expected
if 'language' in expected:
assert {'English', 'Chinese - Mandarin'} == {*expected['language']}
else:
assert {'en-us', 'zh-cmn'} == {*expected['languages']}
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
serializer = ProgramSearchSerializer(result)
expected = self._create_expected_data(program)
assert serializer.data == expected
assert {'English', 'Chinese - Mandarin'} == {*expected['language']}
class ProgramSearchModelSerializerTest(TestProgramSearchSerializer):
serializer_class = ProgramSearchModelSerializer
@classmethod
def get_expected_data(cls, program, request):
expected = ProgramSerializerTests.get_expected_data(program, request)
expected.update({'content_type': 'program'})
return expected
@pytest.mark.django_db
@pytest.mark.usefixtures('haystack_default_connection')
class TestTypeaheadCourseRunSearchSerializer:
def test_data(self):
authoring_organization = OrganizationFactory()
course_run = CourseRunFactory(authoring_organizations=[authoring_organization])
serialized_course = self.serialize_course_run(course_run)
serializer_class = TypeaheadCourseRunSearchSerializer
expected = {
@classmethod
def get_expected_data(cls, course_run):
return {
'key': course_run.key,
'title': course_run.title,
'orgs': [org.key for org in course_run.authoring_organizations.all()],
'marketing_url': course_run.marketing_url,
}
assert serialized_course.data == expected
def test_data(self):
authoring_organization = OrganizationFactory()
course_run = CourseRunFactory(authoring_organizations=[authoring_organization])
serialized_course = self.serialize_course_run(course_run)
assert serialized_course.data == self.get_expected_data(course_run)
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)
serializer = self.serializer_class(result)
return serializer
@pytest.mark.django_db
@pytest.mark.usefixtures('haystack_default_connection')
class TestTypeaheadProgramSearchSerializer:
def _create_expected_data(self, program):
serializer_class = TypeaheadProgramSearchSerializer
@classmethod
def get_expected_data(cls, program):
return {
'uuid': str(program.uuid),
'title': program.title,
......@@ -1417,7 +1491,7 @@ class TestTypeaheadProgramSearchSerializer:
authoring_organization = OrganizationFactory()
program = ProgramFactory(authoring_organizations=[authoring_organization])
serialized_program = self.serialize_program(program)
expected = self._create_expected_data(program)
expected = self.get_expected_data(program)
assert serialized_program.data == expected
def test_data_multiple_authoring_organizations(self):
......@@ -1430,7 +1504,7 @@ class TestTypeaheadProgramSearchSerializer:
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)
serializer = self.serializer_class(result)
return serializer
......
import hashlib
import logging
import six
logger = logging.getLogger(__name__)
......
......@@ -4,16 +4,15 @@ import json
import responses
from django.conf import settings
from haystack.query import SearchQuerySet
from rest_framework.test import APITestCase as RestAPITestCase
from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.serializers import (
CatalogCourseSerializer, CatalogSerializer, CourseRunWithProgramsSerializer,
CourseWithProgramsSerializer, FlattenedCourseRunWithCourseSerializer, MinimalProgramSerializer,
OrganizationSerializer, PersonSerializer, ProgramSerializer, ProgramTypeSerializer, SubjectSerializer,
TopicSerializer
)
from course_discovery.apps.api import serializers
from course_discovery.apps.api.tests.mixins import SiteMixin
from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory
from course_discovery.apps.course_metadata.models import CourseRun, Program
from course_discovery.apps.course_metadata.tests import factories
class SerializationMixin:
......@@ -32,47 +31,69 @@ class SerializationMixin:
context = {'request': self._get_request(format)}
if extra_context:
context.update(extra_context)
return serializer(obj, many=many, context=context).data
def _get_search_result(self, model, **kwargs):
return SearchQuerySet().models(model).filter(**kwargs)[0]
def serialize_catalog(self, catalog, many=False, format=None, extra_context=None):
return self._serialize_object(CatalogSerializer, catalog, many, format, extra_context)
return self._serialize_object(serializers.CatalogSerializer, catalog, many, format, extra_context)
def serialize_course(self, course, many=False, format=None, extra_context=None):
return self._serialize_object(CourseWithProgramsSerializer, course, many, format, extra_context)
return self._serialize_object(serializers.CourseWithProgramsSerializer, course, many, format, extra_context)
def serialize_course_run(self, run, many=False, format=None, extra_context=None):
return self._serialize_object(CourseRunWithProgramsSerializer, run, many, format, extra_context)
return self._serialize_object(serializers.CourseRunWithProgramsSerializer, run, many, format, extra_context)
def serialize_course_run_search(self, run, serializer=None):
obj = self._get_search_result(CourseRun, key=run.key)
return self._serialize_object(serializer or serializers.CourseRunSearchSerializer, obj)
def serialize_person(self, person, many=False, format=None, extra_context=None):
return self._serialize_object(PersonSerializer, person, many, format, extra_context)
return self._serialize_object(serializers.PersonSerializer, person, many, format, extra_context)
def serialize_program(self, program, many=False, format=None, extra_context=None):
return self._serialize_object(
MinimalProgramSerializer if many else ProgramSerializer,
serializers.MinimalProgramSerializer if many else serializers.ProgramSerializer,
program,
many,
format,
extra_context
)
def serialize_program_search(self, program, serializer=None):
obj = self._get_search_result(Program, uuid=program.uuid)
return self._serialize_object(serializer or serializers.ProgramSearchSerializer, obj)
def serialize_program_type(self, program_type, many=False, format=None, extra_context=None):
return self._serialize_object(ProgramTypeSerializer, program_type, many, format, extra_context)
return self._serialize_object(serializers.ProgramTypeSerializer, program_type, many, format, extra_context)
def serialize_catalog_course(self, course, many=False, format=None, extra_context=None):
return self._serialize_object(CatalogCourseSerializer, course, many, format, extra_context)
return self._serialize_object(serializers.CatalogCourseSerializer, course, many, format, extra_context)
def serialize_catalog_flat_course_run(self, course_run, many=False, format=None, extra_context=None):
return self._serialize_object(FlattenedCourseRunWithCourseSerializer, course_run, many, format, extra_context)
return self._serialize_object(
serializers.FlattenedCourseRunWithCourseSerializer, course_run, many, format, extra_context
)
def serialize_organization(self, organization, many=False, format=None, extra_context=None):
return self._serialize_object(OrganizationSerializer, organization, many, format, extra_context)
return self._serialize_object(serializers.OrganizationSerializer, organization, many, format, extra_context)
def serialize_subject(self, subject, many=False, format=None, extra_context=None):
return self._serialize_object(SubjectSerializer, subject, many, format, extra_context)
return self._serialize_object(serializers.SubjectSerializer, subject, many, format, extra_context)
def serialize_topic(self, topic, many=False, format=None, extra_context=None):
return self._serialize_object(TopicSerializer, topic, many, format, extra_context)
return self._serialize_object(serializers.TopicSerializer, topic, many, format, extra_context)
class TypeaheadSerializationMixin:
def serialize_course_run_search(self, run):
obj = SearchQuerySet().models(CourseRun).filter(key=run.key)[0]
return serializers.TypeaheadCourseRunSearchSerializer(obj).data
def serialize_program_search(self, program):
obj = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
return serializers.TypeaheadProgramSearchSerializer(obj).data
class OAuth2Mixin(object):
......@@ -99,5 +120,54 @@ class OAuth2Mixin(object):
)
class SynonymTestMixin:
def test_org_synonyms(self):
""" Test that synonyms work for organization names """
title = 'UniversityX'
authoring_organizations = [factories.OrganizationFactory(name='University')]
factories.CourseRunFactory(
title=title,
course__partner=self.partner,
authoring_organizations=authoring_organizations
)
factories.ProgramFactory(title=title, partner=self.partner, authoring_organizations=authoring_organizations)
response1 = self.process_response({'q': title})
response2 = self.process_response({'q': 'University'})
assert response1 == response2
def test_title_synonyms(self):
""" Test that synonyms work for terms in the title """
factories.CourseRunFactory(title='HTML', course__partner=self.partner)
factories.ProgramFactory(title='HTML', partner=self.partner)
response1 = self.process_response({'q': 'HTML5'})
response2 = self.process_response({'q': 'HTML'})
assert response1 == response2
def test_special_character_synonyms(self):
""" Test that synonyms work with special characters (non ascii) """
factories.ProgramFactory(title='spanish', partner=self.partner)
response1 = self.process_response({'q': 'spanish'})
response2 = self.process_response({'q': 'español'})
assert response1 == response2
def test_stemmed_synonyms(self):
""" Test that synonyms work with stemming from the snowball analyzer """
title = 'Running'
factories.ProgramFactory(title=title, partner=self.partner)
response1 = self.process_response({'q': 'running'})
response2 = self.process_response({'q': 'jogging'})
assert response1 == response2
class LoginMixin:
def setUp(self):
super(LoginMixin, self).setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password=USER_PASSWORD)
if getattr(self, 'request'):
self.request.user = self.user
class APITestCase(SiteMixin, RestAPITestCase):
pass
......@@ -2,7 +2,6 @@ import datetime
import ddt
import pytz
from django.core.cache import cache
from django.db.models.functions import Lower
from rest_framework.reverse import reverse
......
......@@ -115,7 +115,7 @@ class TestProgramViewSet(SerializationMixin):
partner=self.partner)
# property does not have the right values while being indexed
del program._course_run_weeks_to_complete
with django_assert_num_queries(38):
with django_assert_num_queries(39):
response = self.assert_retrieve_success(program)
assert response.data == self.serialize_program(program)
assert course_list == list(program.courses.all()) # pylint: disable=no-member
......@@ -124,7 +124,7 @@ class TestProgramViewSet(SerializationMixin):
""" Verify the endpoint returns data for a program even if the program's courses have no course runs. """
course = CourseFactory(partner=self.partner)
program = ProgramFactory(courses=[course], partner=self.partner)
with django_assert_num_queries(25):
with django_assert_num_queries(26):
response = self.assert_retrieve_success(program)
assert response.data == self.serialize_program(program)
......
import datetime
import json
import urllib.parse
import ddt
import pytz
from django.urls import reverse
from haystack.query import SearchQuerySet
from course_discovery.apps.api.serializers import (CourseRunSearchSerializer, ProgramSearchSerializer,
TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer)
from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase
from course_discovery.apps.api import serializers
from course_discovery.apps.api.v1.tests.test_views import mixins
from course_discovery.apps.api.v1.views.search import TypeaheadSearchView
from course_discovery.apps.core.tests.factories import USER_PASSWORD, PartnerFactory, UserFactory
from course_discovery.apps.core.tests.factories import PartnerFactory
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.models import CourseRun, Program
from course_discovery.apps.course_metadata.tests.factories import (CourseFactory, CourseRunFactory, OrganizationFactory,
ProgramFactory)
class SerializationMixin:
def serialize_course_run(self, course_run):
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
return CourseRunSearchSerializer(result).data
def serialize_program(self, program):
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
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
return data
def serialize_program(self, program):
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
data = TypeaheadProgramSearchSerializer(result).data
return data
class LoginMixin:
def setUp(self):
super(LoginMixin, self).setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password=USER_PASSWORD)
class SynonymTestMixin:
def test_org_synonyms(self):
""" Test that synonyms work for organization names """
title = 'UniversityX'
authoring_organizations = [OrganizationFactory(name='University')]
CourseRunFactory(
title=title,
course__partner=self.partner,
authoring_organizations=authoring_organizations
)
ProgramFactory(title=title, partner=self.partner, authoring_organizations=authoring_organizations)
response1 = self.process_response({'q': title})
response2 = self.process_response({'q': 'University'})
self.assertDictEqual(response1, response2)
def test_title_synonyms(self):
""" Test that synonyms work for terms in the title """
CourseRunFactory(title='HTML', course__partner=self.partner)
ProgramFactory(title='HTML', partner=self.partner)
response1 = self.process_response({'q': 'HTML5'})
response2 = self.process_response({'q': 'HTML'})
self.assertDictEqual(response1, response2)
def test_special_character_synonyms(self):
""" Test that synonyms work with special characters (non ascii) """
ProgramFactory(title='spanish', partner=self.partner)
response1 = self.process_response({'q': 'spanish'})
response2 = self.process_response({'q': 'español'})
self.assertDictEqual(response1, response2)
def test_stemmed_synonyms(self):
""" Test that synonyms work with stemming from the snowball analyzer """
title = 'Running'
ProgramFactory(title=title, partner=self.partner)
response1 = self.process_response({'q': 'running'})
response2 = self.process_response({'q': 'jogging'})
self.assertDictEqual(response1, response2)
from course_discovery.apps.course_metadata.models import CourseRun
from course_discovery.apps.course_metadata.tests.factories import (
CourseFactory, CourseRunFactory, OrganizationFactory, ProgramFactory
)
@ddt.ddt
class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchTestMixin,
APITestCase):
class CourseRunSearchViewSetTests(mixins.SerializationMixin, mixins.LoginMixin, ElasticsearchTestMixin,
mixins.APITestCase):
""" Tests for CourseRunSearchViewSet. """
detailed_path = reverse('api:v1:search-course_runs-details')
faceted_path = reverse('api:v1:search-course_runs-facets')
list_path = reverse('api:v1:search-course_runs-list')
def get_response(self, query=None, faceted=False):
qs = ''
if query:
qs = urllib.parse.urlencode({'q': query})
path = self.faceted_path if faceted else self.list_path
def get_response(self, query=None, path=None):
qs = urllib.parse.urlencode({'q': query}) if query else ''
path = path or self.list_path
url = '{path}?{qs}'.format(path=path, qs=qs)
return self.client.get(url)
def process_response(self, response):
response = self.get_response(response).json()
self.assertTrue(response['objects']['count'])
return response['objects']
@ddt.data(True, False)
def test_authentication(self, faceted):
""" Verify the endpoint requires authentication. """
self.client.logout()
response = self.get_response(faceted=faceted)
self.assertEqual(response.status_code, 403)
def test_search(self):
""" Verify the view returns search results. """
self.assert_successful_search(faceted=False)
def test_faceted_search(self):
""" Verify the view returns results and facets. """
course_run, response_data = self.assert_successful_search(faceted=True)
# Validate the pacing facet
expected = {
'text': course_run.pacing_type,
'count': 1,
}
self.assertDictContainsSubset(expected, response_data['fields']['pacing_type'][0])
def build_facet_url(self, params):
return 'http://testserver.fake{path}?{query}'.format(
path=self.faceted_path, query=urllib.parse.urlencode(params)
)
def assert_successful_search(self, faceted=False):
def assert_successful_search(self, path=None, serializer=None):
""" Asserts the search functionality returns results for a generated query. """
# Generate data that should be indexed and returned by the query
course_run = CourseRunFactory(course__partner=self.partner, course__title='Software Testing',
status=CourseRunStatus.Published)
response = self.get_response('software', faceted=faceted)
response = self.get_response('software', path=path)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
assert response.status_code == 200
response_data = response.json()
# Validate the search results
expected = {
'count': 1,
'results': [
self.serialize_course_run(course_run)
self.serialize_course_run_search(course_run, serializer=serializer)
]
}
actual = response_data['objects'] if faceted else response_data
actual = response_data['objects'] if path == self.faceted_path else response_data
self.assertDictContainsSubset(expected, actual)
return course_run, response_data
def build_facet_url(self, params):
return 'http://testserver.fake{path}?{query}'.format(
path=self.faceted_path, query=urllib.parse.urlencode(params)
)
def assert_response_includes_availability_facets(self, response_data):
""" Verifies the query facet counts/URLs are properly rendered. """
expected = {
'availability_archived': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_archived'})
},
'availability_current': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_current'})
},
'availability_starting_soon': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_starting_soon'})
},
'availability_upcoming': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_upcoming'})
},
}
self.assertDictContainsSubset(expected, response_data['queries'])
@ddt.data(faceted_path, list_path, detailed_path)
def test_authentication(self, path):
""" Verify the endpoint requires authentication. """
self.client.logout()
response = self.get_response(path=path)
assert response.status_code == 403
@ddt.data(
(list_path, serializers.CourseRunSearchSerializer,),
(detailed_path, serializers.CourseRunSearchModelSerializer,),
)
@ddt.unpack
def test_search(self, path, serializer):
""" Verify the view returns search results. """
self.assert_successful_search(path=path, serializer=serializer)
def test_faceted_search(self):
""" Verify the view returns results and facets. """
course_run, response_data = self.assert_successful_search(path=self.faceted_path)
# Validate the pacing facet
expected = {
'text': course_run.pacing_type,
'count': 1,
}
self.assertDictContainsSubset(expected, response_data['fields']['pacing_type'][0])
def test_invalid_query_facet(self):
""" Verify the endpoint returns HTTP 400 if an invalid facet is requested. """
......@@ -166,11 +113,11 @@ class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
url = '{path}?selected_query_facets={facet}'.format(path=self.faceted_path, facet=facet)
response = self.client.get(url)
self.assertEqual(response.status_code, 400)
assert response.status_code == 400
response_data = json.loads(response.content.decode('utf-8'))
response_data = response.json()
expected = {'detail': 'The selected query facet [{facet}] is not valid.'.format(facet=facet)}
self.assertEqual(response_data, expected)
assert response_data == expected
def test_availability_faceting(self):
""" Verify the endpoint returns availability facets with the results. """
......@@ -184,18 +131,18 @@ class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
upcoming = CourseRunFactory(course__partner=self.partner, start=now + datetime.timedelta(days=61),
end=now + datetime.timedelta(days=90), status=CourseRunStatus.Published)
response = self.get_response(faceted=True)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
response = self.get_response(path=self.faceted_path)
assert response.status_code == 200
response_data = response.json()
# Verify all course runs are returned
self.assertEqual(response_data['objects']['count'], 4)
assert response_data['objects']['count'] == 4
for run in [archived, current, starting_soon, upcoming]:
serialized = self.serialize_course_run(run)
serialized = self.serialize_course_run_search(run)
# Force execution of lazy function.
serialized['availability'] = serialized['availability'].strip()
self.assertIn(serialized, response_data['objects']['results'])
assert serialized in response_data['objects']['results']
self.assert_response_includes_availability_facets(response_data)
......@@ -204,39 +151,48 @@ class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
path=self.faceted_path, facet='availability_archived'
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(response_data['objects']['results'], [self.serialize_course_run(archived)])
assert response.status_code == 200
response_data = response.json()
assert response_data['objects']['results'] == [self.serialize_course_run_search(archived)]
def assert_response_includes_availability_facets(self, response_data):
""" Verifies the query facet counts/URLs are properly rendered. """
expected = {
'availability_archived': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_archived'})
},
'availability_current': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_current'})
},
'availability_starting_soon': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_starting_soon'})
},
'availability_upcoming': {
'count': 1,
'narrow_url': self.build_facet_url({'selected_query_facets': 'availability_upcoming'})
},
}
self.assertDictContainsSubset(expected, response_data['queries'])
@ddt.data(
(list_path, serializers.CourseRunSearchSerializer,
['results', 0, 'program_types', 0], ProgramStatus.Deleted, 6),
(list_path, serializers.CourseRunSearchSerializer,
['results', 0, 'program_types', 0], ProgramStatus.Unpublished, 6),
(detailed_path, serializers.CourseRunSearchModelSerializer,
['results', 0, 'programs', 0, 'type'], ProgramStatus.Deleted, 35),
(detailed_path, serializers.CourseRunSearchModelSerializer,
['results', 0, 'programs', 0, 'type'], ProgramStatus.Unpublished, 36),
)
@ddt.unpack
def test_exclude_unavailable_program_types(self, path, serializer, result_location_keys, program_status,
expected_queries):
""" Verify that unavailable programs do not show in the program_types representation. """
course_run = CourseRunFactory(course__partner=self.partner, course__title='Software Testing',
status=CourseRunStatus.Published)
active_program = ProgramFactory(courses=[course_run.course], status=ProgramStatus.Active)
ProgramFactory(courses=[course_run.course], status=program_status)
self.reindex_courses(active_program)
with self.assertNumQueries(expected_queries):
response = self.get_response('software', path=path)
assert response.status_code == 200
response_data = response.json()
def test_exclude_deleted_program_types(self):
""" Verify the deleted programs do not show in the program_types representation. """
self._test_exclude_program_types(ProgramStatus.Deleted)
# Validate the search results
expected = {
'count': 1,
'results': [
self.serialize_course_run_search(course_run, serializer=serializer)
]
}
self.assertDictContainsSubset(expected, response_data)
def test_exclude_unpublished_program_types(self):
""" Verify the unpublished programs do not show in the program_types representation. """
self._test_exclude_program_types(ProgramStatus.Unpublished)
# Check that the program is indeed the active one.
for key in result_location_keys:
response_data = response_data[key]
assert response_data == active_program.type.name
@ddt.data(
[{'title': 'Software Testing', 'excluded': True}],
......@@ -268,49 +224,25 @@ class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
self.reindex_courses(program)
with self.assertNumQueries(5):
response = self.get_response('software', faceted=False)
response = self.get_response('software', path=self.list_path)
self.assertEqual(response.status_code, 200)
assert response.status_code == 200
response_data = response.json()
self.assertEqual(response_data['count'], len(course_run_list))
assert response_data['count'] == len(course_run_list)
for result in response_data['results']:
for course_run in excluded_course_run_list:
if result.get('title') == course_run.title:
self.assertEqual(result.get('program_types'), [])
assert result.get('program_types') == []
for course_run in non_excluded_course_run_list:
if result.get('title') == course_run.title:
self.assertEqual(result.get('program_types'), course_run.program_types)
def _test_exclude_program_types(self, program_status):
""" Verify that programs with the provided type do not show in the program_types representation. """
course_run = CourseRunFactory(course__partner=self.partner, course__title='Software Testing',
status=CourseRunStatus.Published)
active_program = ProgramFactory(courses=[course_run.course], status=ProgramStatus.Active)
ProgramFactory(courses=[course_run.course], status=program_status)
self.reindex_courses(active_program)
with self.assertNumQueries(6):
response = self.get_response('software', faceted=False)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
# Validate the search results
expected = {
'count': 1,
'results': [
self.serialize_course_run(course_run)
]
}
self.assertDictContainsSubset(expected, response_data)
self.assertEqual(response_data['results'][0].get('program_types'), [active_program.type.name])
assert result.get('program_types') == course_run.program_types
@ddt.ddt
class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchTestMixin,
SynonymTestMixin, APITestCase):
class AggregateSearchViewSetTests(mixins.SerializationMixin, mixins.LoginMixin, ElasticsearchTestMixin,
mixins.SynonymTestMixin, mixins.APITestCase):
path = reverse('api:v1:search-all-facets')
def get_response(self, query=None):
......@@ -338,26 +270,21 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active)
response = self.get_response()
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertListEqual(
response_data['objects']['results'],
[self.serialize_program(program), self.serialize_course_run(course_run)]
)
assert response.status_code == 200
response_data = response.json()
assert response_data['objects']['results'] == \
[self.serialize_program_search(program), self.serialize_course_run_search(course_run)]
def test_hidden_runs_excluded(self):
"""Search results should not include hidden runs."""
visible_run = CourseRunFactory(course__partner=self.partner)
hidden_run = CourseRunFactory(course__partner=self.partner, hidden=True)
self.assertEqual(CourseRun.objects.get(hidden=True), hidden_run)
assert CourseRun.objects.get(hidden=True) == hidden_run
response = self.get_response()
data = json.loads(response.content.decode('utf-8'))
self.assertEqual(
data['objects']['results'],
[self.serialize_course_run(visible_run)]
)
data = response.json()
assert data['objects']['results'] == [self.serialize_course_run_search(visible_run)]
def test_results_filtered_by_default_partner(self):
""" Verify the search results only include items related to the default partner if no partner is
......@@ -369,23 +296,21 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
other_partner = PartnerFactory()
other_course_run = CourseRunFactory(course__partner=other_partner, status=CourseRunStatus.Published)
other_program = ProgramFactory(partner=other_partner, status=ProgramStatus.Active)
self.assertNotEqual(other_program.partner.short_code, self.partner.short_code)
self.assertNotEqual(other_course_run.course.partner.short_code, self.partner.short_code)
assert other_program.partner.short_code != self.partner.short_code
assert other_course_run.course.partner.short_code != self.partner.short_code
response = self.get_response()
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertListEqual(
response_data['objects']['results'],
[self.serialize_program(program), self.serialize_course_run(course_run)]
)
assert response.status_code == 200
response_data = response.json()
assert response_data['objects']['results'] == \
[self.serialize_program_search(program), self.serialize_course_run_search(course_run)]
# Filter results by partner
response = self.get_response({'partner': other_partner.short_code})
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertListEqual(response_data['objects']['results'],
[self.serialize_program(other_program), self.serialize_course_run(other_course_run)])
assert response.status_code == 200
response_data = response.json()
assert response_data['objects']['results'] == \
[self.serialize_program_search(other_program), self.serialize_course_run_search(other_course_run)]
def test_empty_query(self):
""" Verify, when the query (q) parameter is empty, the endpoint behaves as if the parameter
......@@ -394,10 +319,10 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
program = ProgramFactory(partner=self.partner, status=ProgramStatus.Active)
response = self.get_response({'q': '', 'content_type': ['courserun', 'program']})
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertListEqual(response_data['objects']['results'],
[self.serialize_program(program), self.serialize_course_run(course_run)])
assert response.status_code == 200
response_data = response.json()
assert response_data['objects']['results'] == \
[self.serialize_program_search(program), self.serialize_course_run_search(course_run)]
@ddt.data('start', '-start')
def test_results_ordered_by_start_date(self, ordering):
......@@ -410,12 +335,12 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
course_run_keys = [course_run.key for course_run in [archived, current, starting_soon, upcoming]]
response = self.get_response({"ordering": ordering})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['objects']['count'], 4)
assert response.status_code == 200
assert response.data['objects']['count'] == 4
course_runs = CourseRun.objects.filter(key__in=course_run_keys).order_by(ordering)
expected = [self.serialize_course_run(course_run) for course_run in course_runs]
self.assertEqual(response.data['objects']['results'], expected)
expected = [self.serialize_course_run_search(course_run) for course_run in course_runs]
assert response.data['objects']['results'] == expected
def test_results_include_aggregation_key(self):
""" Verify the search results only include the aggregation_key for each document. """
......@@ -424,7 +349,7 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
response = self.get_response()
assert response.status_code == 200
response_data = json.loads(response.content.decode('utf-8'))
response_data = response.json()
expected = sorted(
['courserun:{}'.format(course_run.course.key), 'program:{}'.format(program.uuid)]
......@@ -435,8 +360,8 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
assert expected == actual
class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, ElasticsearchTestMixin,
SynonymTestMixin, APITestCase):
class TypeaheadSearchViewTests(mixins.TypeaheadSerializationMixin, mixins.LoginMixin, ElasticsearchTestMixin,
mixins.SynonymTestMixin, mixins.APITestCase):
path = reverse('api:v1:search-typeahead')
def get_response(self, query=None, partner=None):
......@@ -460,8 +385,8 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
response = self.get_response({'q': title})
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)]})
self.assertDictEqual(response_data, {'course_runs': [self.serialize_course_run_search(course_run)],
'programs': [self.serialize_program_search(program)]})
def test_typeahead_multiple_results(self):
""" Verify the typeahead responses always returns a limited number of results, even if there are more hits. """
......@@ -512,8 +437,8 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
response = self.get_response({'q': title})
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)]})
self.assertDictEqual(response_data, {'course_runs': [self.serialize_course_run_search(course_run)],
'programs': [self.serialize_program_search(program)]})
def test_partial_term_search(self):
""" Test typeahead response with partial term search. """
......@@ -525,8 +450,8 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
self.assertEqual(response.status_code, 200)
response_data = response.json()
expected_response_data = {
'course_runs': [self.serialize_course_run(course_run)],
'programs': [self.serialize_program(program)]
'course_runs': [self.serialize_course_run_search(course_run)],
'programs': [self.serialize_program_search(program)]
}
self.assertDictEqual(response_data, expected_response_data)
......@@ -544,8 +469,8 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
self.assertEqual(response.status_code, 200)
response_data = response.json()
expected_response_data = {
'course_runs': [self.serialize_course_run(course_run)],
'programs': [self.serialize_program(program)]
'course_runs': [self.serialize_course_run_search(course_run)],
'programs': [self.serialize_program_search(program)]
}
self.assertDictEqual(response_data, expected_response_data)
......@@ -559,7 +484,7 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
response_data = response.json()
expected_response_data = {
'course_runs': [],
'programs': [self.serialize_program(program)]
'programs': [self.serialize_program_search(program)]
}
self.assertDictEqual(response_data, expected_response_data)
......@@ -579,8 +504,8 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
response = self.get_response({'q': partial_key})
self.assertEqual(response.status_code, 200)
expected = {
'course_runs': [self.serialize_course_run(course_run)],
'programs': [self.serialize_program(program)]
'course_runs': [self.serialize_course_run_search(course_run)],
'programs': [self.serialize_program_search(program)]
}
self.assertDictEqual(response.data, expected)
......@@ -611,9 +536,9 @@ class TypeaheadSearchViewTests(TypeaheadSerializationMixin, LoginMixin, Elastics
response = self.get_response({'q': 'mit'})
self.assertEqual(response.status_code, 200)
expected = {
'course_runs': [self.serialize_course_run(mit_run),
self.serialize_course_run(harvard_run)],
'programs': [self.serialize_program(mit_program),
self.serialize_program(harvard_program)]
'course_runs': [self.serialize_course_run_search(mit_run),
self.serialize_course_run_search(harvard_run)],
'programs': [self.serialize_program_search(mit_program),
self.serialize_program_search(harvard_program)]
}
self.assertDictEqual(response.data, expected)
from django.conf import settings
from django.contrib.auth import get_user_model
from course_discovery.apps.api import serializers
from course_discovery.apps.api.exceptions import InvalidPartnerError
from course_discovery.apps.core.models import Partner
User = get_user_model()
......
......@@ -11,12 +11,12 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api import filters, mixins, serializers
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
class BaseHaystackViewSet(mixins.DetailMixin, FacetMixin, HaystackViewSet):
document_uid_field = 'key'
facet_filter_backends = [filters.HaystackFacetFilterWithQueries, filters.HaystackFilter, OrderingFilter]
ordering_fields = ('start',)
......@@ -93,27 +93,31 @@ class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
class CourseSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseFacetSerializer
index_models = (Course,)
detail_serializer_class = serializers.CourseSearchModelSerializer
facet_serializer_class = serializers.CourseFacetSerializer
serializer_class = serializers.CourseSearchSerializer
class CourseRunSearchViewSet(BaseHaystackViewSet):
facet_serializer_class = serializers.CourseRunFacetSerializer
index_models = (CourseRun,)
detail_serializer_class = serializers.CourseRunSearchModelSerializer
facet_serializer_class = serializers.CourseRunFacetSerializer
serializer_class = serializers.CourseRunSearchSerializer
class ProgramSearchViewSet(BaseHaystackViewSet):
document_uid_field = 'uuid'
lookup_field = 'uuid'
facet_serializer_class = serializers.ProgramFacetSerializer
index_models = (Program,)
detail_serializer_class = serializers.ProgramSearchModelSerializer
facet_serializer_class = serializers.ProgramFacetSerializer
serializer_class = serializers.ProgramSearchSerializer
class AggregateSearchViewSet(BaseHaystackViewSet):
""" Search all content types. """
detail_serializer_class = serializers.AggregateSearchModelSerializer
facet_serializer_class = serializers.AggregateFacetSearchSerializer
serializer_class = serializers.AggregateSearchSerializer
......
......@@ -4,7 +4,6 @@ from __future__ import unicode_literals
from django.db import migrations
SWITCH = 'use_company_name_as_utm_source_value'
......
......@@ -3,7 +3,6 @@ import logging
import pytest
import responses
from django.conf import settings
from haystack import connections as haystack_connections
......
......@@ -26,15 +26,13 @@ from taggit_autosuggest.managers import TaggableManager
from course_discovery.apps.core.models import Currency, Partner
from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus, ProgramStatus, ReportingType
from course_discovery.apps.course_metadata.publishers import (
CourseRunMarketingSitePublisher,
ProgramMarketingSitePublisher
CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher
)
from course_discovery.apps.course_metadata.query import CourseQuerySet, CourseRunQuerySet, ProgramQuerySet
from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath, clean_query, custom_render_variations
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.utils import VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY
logger = logging.getLogger(__name__)
......
......@@ -8,12 +8,7 @@ from django.utils.text import slugify
from course_discovery.apps.course_metadata.choices import CourseRunStatus
from course_discovery.apps.course_metadata.exceptions import (
AliasCreateError,
AliasDeleteError,
FormRetrievalError,
NodeCreateError,
NodeDeleteError,
NodeEditError,
AliasCreateError, AliasDeleteError, FormRetrievalError, NodeCreateError, NodeDeleteError, NodeEditError,
NodeLookupError
)
from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClient
......
......@@ -6,6 +6,26 @@ from opaque_keys.edx.keys import CourseKey
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
BASE_SEARCH_INDEX_FIELDS = (
'aggregation_key',
'content_type',
'text',
)
BASE_PROGRAM_FIELDS = (
'card_image_url',
'language',
'marketing_url',
'partner',
'published',
'status',
'subtitle',
'text',
'title',
'type',
'uuid'
)
# http://django-haystack.readthedocs.io/en/v2.5.0/boost.html#field-boost
# Boost title over all other parameters (multiplicative)
# The max boost received from our boosting functions is ~6.
......
......@@ -270,6 +270,7 @@ class ProgramFactory(factory.django.DjangoModelFactory):
banner_image_url = FuzzyText(prefix='https://example.com/program/banner')
card_image_url = FuzzyText(prefix='https://example.com/program/card')
partner = factory.SubFactory(PartnerFactory)
video = factory.SubFactory(VideoFactory)
overview = FuzzyText()
total_hours_of_effort = FuzzyInteger(2)
weeks_to_complete = FuzzyInteger(1)
......
......@@ -7,18 +7,11 @@ import responses
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.exceptions import (
AliasCreateError,
AliasDeleteError,
FormRetrievalError,
NodeCreateError,
NodeDeleteError,
NodeEditError,
AliasCreateError, AliasDeleteError, FormRetrievalError, NodeCreateError, NodeDeleteError, NodeEditError,
NodeLookupError
)
from course_discovery.apps.course_metadata.publishers import (
BaseMarketingSitePublisher,
CourseRunMarketingSitePublisher,
ProgramMarketingSitePublisher
BaseMarketingSitePublisher, CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher
)
from course_discovery.apps.course_metadata.tests import toggle_switch
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory
......
......@@ -4,10 +4,10 @@ import urllib.parse
import pytz
from django.urls import reverse
from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase
from course_discovery.apps.api.v1.tests.test_views.test_search import (
ElasticsearchTestMixin, LoginMixin, SerializationMixin, SynonymTestMixin
from course_discovery.apps.api.v1.tests.test_views.mixins import (
APITestCase, LoginMixin, SerializationMixin, SynonymTestMixin
)
from course_discovery.apps.api.v1.tests.test_views.test_search import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.tests.factories import CourseFactory, CourseRunFactory, ProgramFactory
from course_discovery.apps.edx_catalog_extensions.api.serializers import DistinctCountsAggregateFacetSearchSerializer
......@@ -125,9 +125,9 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin,
for record in objects['results']:
if record['content_type'] == 'courserun':
assert record == self.serialize_course_run(course_runs[str(record['key'])])
assert record == self.serialize_course_run_search(course_runs[str(record['key'])])
else:
assert record == self.serialize_program(programs[str(record['uuid'])])
assert record == self.serialize_program_search(programs[str(record['uuid'])])
def test_response_with_search_query(self):
""" Verify that the response is accurate when a search query is passed."""
......
import elasticsearch
from django.conf import settings
from haystack.backends.elasticsearch_backend import ElasticsearchSearchQuery
from haystack.models import SearchResult
......
from django.conf import settings
from haystack.query import SearchQuerySet
from course_discovery.apps.edx_haystack_extensions.distinct_counts.backends import DistinctCountsSearchQuery
......
......@@ -4,7 +4,6 @@ from django.conf import settings
from django.core.management.base import BaseCommand
from haystack import connections as haystack_connections
logger = logging.getLogger(__name__)
......
......@@ -6,16 +6,18 @@ from simple_history.admin import SimpleHistoryAdmin
from course_discovery.apps.publisher.assign_permissions import assign_permissions
from course_discovery.apps.publisher.choices import InternalUserRole
from course_discovery.apps.publisher.constants import (INTERNAL_USER_GROUP_NAME, PARTNER_MANAGER_GROUP_NAME,
PROJECT_COORDINATOR_GROUP_NAME, PUBLISHER_GROUP_NAME,
REVIEWER_GROUP_NAME)
from course_discovery.apps.publisher.constants import (
INTERNAL_USER_GROUP_NAME, PARTNER_MANAGER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME, PUBLISHER_GROUP_NAME,
REVIEWER_GROUP_NAME
)
from course_discovery.apps.publisher.forms import (
CourseRunAdminForm, CourseRunStateAdminForm, CourseStateAdminForm, OrganizationExtensionForm,
PublisherUserCreationForm, UserAttributesAdminForm
)
from course_discovery.apps.publisher.models import (Course, CourseEntitlement, CourseRun, CourseRunState, CourseState,
CourseUserRole, OrganizationExtension, OrganizationUserRole,
PublisherUser, Seat, UserAttributes)
from course_discovery.apps.publisher.models import (
Course, CourseEntitlement, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension,
OrganizationUserRole, PublisherUser, Seat, UserAttributes
)
@admin.register(CourseUserRole)
......
......@@ -13,9 +13,10 @@ from rest_framework import serializers
from course_discovery.apps.core.models import User
from course_discovery.apps.publisher.choices import PublisherUserRole
from course_discovery.apps.publisher.emails import (send_change_role_assignment_email,
send_email_for_studio_instance_created, send_email_preview_accepted,
send_email_preview_page_is_available)
from course_discovery.apps.publisher.emails import (
send_change_role_assignment_email, send_email_for_studio_instance_created, send_email_preview_accepted,
send_email_preview_page_is_available
)
from course_discovery.apps.publisher.models import CourseRun, CourseRunState, CourseState, CourseUserRole
......
......@@ -11,14 +11,16 @@ from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.course_metadata.tests import toggle_switch
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory, PersonFactory
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.api.serializers import (CourseRevisionSerializer, CourseRunSerializer,
CourseRunStateSerializer, CourseStateSerializer,
CourseUserRoleSerializer, GroupUserSerializer)
from course_discovery.apps.publisher.api.serializers import (
CourseRevisionSerializer, CourseRunSerializer, CourseRunStateSerializer, CourseStateSerializer,
CourseUserRoleSerializer, GroupUserSerializer
)
from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole
from course_discovery.apps.publisher.models import CourseRun, CourseState, Seat
from course_discovery.apps.publisher.tests.factories import (CourseFactory, CourseRunFactory, CourseRunStateFactory,
CourseStateFactory, CourseUserRoleFactory,
OrganizationExtensionFactory, SeatFactory)
from course_discovery.apps.publisher.tests.factories import (
CourseFactory, CourseRunFactory, CourseRunStateFactory, CourseStateFactory, CourseUserRoleFactory,
OrganizationExtensionFactory, SeatFactory
)
class CourseUserRoleSerializerTests(SiteMixin, TestCase):
......
import pytest
from course_discovery.apps.core.utils import serialize_datetime
from course_discovery.apps.publisher.api.utils import (serialize_entitlement_for_ecommerce_api,
serialize_seat_for_ecommerce_api)
from course_discovery.apps.publisher.api.utils import (
serialize_entitlement_for_ecommerce_api, serialize_seat_for_ecommerce_api
)
from course_discovery.apps.publisher.models import Seat
from course_discovery.apps.publisher.tests.factories import CourseEntitlementFactory, SeatFactory
......
......@@ -22,8 +22,9 @@ from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.api import views
from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole
from course_discovery.apps.publisher.constants import ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME
from course_discovery.apps.publisher.models import (Course, CourseRun, CourseRunState, CourseState,
OrganizationExtension, Seat)
from course_discovery.apps.publisher.models import (
Course, CourseRun, CourseRunState, CourseState, OrganizationExtension, Seat
)
from course_discovery.apps.publisher.tests import JSON_CONTENT_TYPE, factories
......
""" Publisher API URLs. """
from django.conf.urls import include, url
from course_discovery.apps.publisher.api.views import (AcceptAllRevisionView, ChangeCourseRunStateView,
ChangeCourseStateView, CourseRevisionDetailView,
CourseRoleAssignmentView, CoursesAutoComplete,
OrganizationGroupUserView, RevertCourseRevisionView,
UpdateCourseRunView)
from course_discovery.apps.publisher.api.views import (
AcceptAllRevisionView, ChangeCourseRunStateView, ChangeCourseStateView, CourseRevisionDetailView,
CourseRoleAssignmentView, CoursesAutoComplete, OrganizationGroupUserView, RevertCourseRevisionView,
UpdateCourseRunView
)
urlpatterns = [
url(r'^course_role_assignments/(?P<pk>\d+)/$', CourseRoleAssignmentView.as_view(), name='course_role_assignments'),
......
......@@ -18,8 +18,9 @@ from course_discovery.apps.course_metadata.models import Seat as DiscoverySeat
from course_discovery.apps.course_metadata.models import CourseRun, SeatType, Video
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory, PersonFactory
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.api.utils import (serialize_entitlement_for_ecommerce_api,
serialize_seat_for_ecommerce_api)
from course_discovery.apps.publisher.api.utils import (
serialize_entitlement_for_ecommerce_api, serialize_seat_for_ecommerce_api
)
from course_discovery.apps.publisher.api.v1.views import CourseRunViewSet
from course_discovery.apps.publisher.models import CourseEntitlement, Seat
from course_discovery.apps.publisher.tests.factories import CourseEntitlementFactory, CourseRunFactory, SeatFactory
......
......@@ -11,14 +11,17 @@ from rest_framework.views import APIView
from course_discovery.apps.core.models import User
from course_discovery.apps.publisher.api.paginations import LargeResultsSetPagination
from course_discovery.apps.publisher.api.permissions import (CanViewAssociatedCourse, InternalUserPermission,
PublisherUserPermission)
from course_discovery.apps.publisher.api.serializers import (CourseRevisionSerializer, CourseRunSerializer,
CourseRunStateSerializer, CourseStateSerializer,
CourseUserRoleSerializer, GroupUserSerializer)
from course_discovery.apps.publisher.api.permissions import (
CanViewAssociatedCourse, InternalUserPermission, PublisherUserPermission
)
from course_discovery.apps.publisher.api.serializers import (
CourseRevisionSerializer, CourseRunSerializer, CourseRunStateSerializer, CourseStateSerializer,
CourseUserRoleSerializer, GroupUserSerializer
)
from course_discovery.apps.publisher.forms import CourseForm
from course_discovery.apps.publisher.models import (Course, CourseRun, CourseRunState, CourseState, CourseUserRole,
OrganizationExtension, PublisherUser)
from course_discovery.apps.publisher.models import (
Course, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension, PublisherUser
)
logger = logging.getLogger(__name__)
......
from django.contrib.auth.models import Group
from guardian.shortcuts import assign_perm
from course_discovery.apps.publisher.constants import (GENERAL_STAFF_GROUP_NAME, LEGAL_TEAM_GROUP_NAME,
PARTNER_SUPPORT_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME,
REVIEWER_GROUP_NAME)
from course_discovery.apps.publisher.constants import (
GENERAL_STAFF_GROUP_NAME, LEGAL_TEAM_GROUP_NAME, PARTNER_SUPPORT_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME,
REVIEWER_GROUP_NAME
)
from course_discovery.apps.publisher.models import OrganizationExtension
......
......@@ -3,8 +3,9 @@ from __future__ import unicode_literals
from django.db import migrations
from course_discovery.apps.publisher.constants import (PARTNER_COORDINATOR_GROUP_NAME, PUBLISHER_GROUP_NAME,
REVIEWER_GROUP_NAME)
from course_discovery.apps.publisher.constants import (
PARTNER_COORDINATOR_GROUP_NAME, PUBLISHER_GROUP_NAME, REVIEWER_GROUP_NAME
)
GROUPS = [PARTNER_COORDINATOR_GROUP_NAME, REVIEWER_GROUP_NAME, PUBLISHER_GROUP_NAME]
......
......@@ -4,8 +4,10 @@ from __future__ import unicode_literals
from django.db import migrations
from course_discovery.apps.publisher.constants import (INTERNAL_USER_GROUP_NAME, PARTNER_COORDINATOR_GROUP_NAME,
PUBLISHER_GROUP_NAME, REVIEWER_GROUP_NAME)
from course_discovery.apps.publisher.constants import (
INTERNAL_USER_GROUP_NAME, PARTNER_COORDINATOR_GROUP_NAME, PUBLISHER_GROUP_NAME, REVIEWER_GROUP_NAME
)
GROUPS = [INTERNAL_USER_GROUP_NAME, PARTNER_COORDINATOR_GROUP_NAME, REVIEWER_GROUP_NAME, PUBLISHER_GROUP_NAME]
......
......@@ -29,7 +29,6 @@ from course_discovery.apps.publisher.choices import (
CourseRunStateChoices, CourseStateChoices, InternalUserRole, PublisherUserRole
)
from course_discovery.apps.publisher.utils import is_email_notification_enabled, is_internal_user, is_publisher_admin
from course_discovery.apps.publisher.validators import ImageMultiSizeValidator
logger = logging.getLogger(__name__)
......
......@@ -12,9 +12,10 @@ from course_discovery.apps.course_metadata.choices import CourseRunPacing
from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.choices import PublisherUserRole
from course_discovery.apps.publisher.models import (Course, CourseEntitlement, CourseRun, CourseRunState, CourseState,
CourseUserRole, OrganizationExtension, OrganizationUserRole, Seat,
UserAttributes)
from course_discovery.apps.publisher.models import (
Course, CourseEntitlement, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension,
OrganizationUserRole, Seat, UserAttributes
)
class CourseFactory(factory.DjangoModelFactory):
......
......@@ -8,8 +8,9 @@ from course_discovery.apps.api.tests.mixins import SiteMixin
from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory
from course_discovery.apps.publisher.choices import PublisherUserRole
from course_discovery.apps.publisher.constants import (PARTNER_MANAGER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME,
PUBLISHER_GROUP_NAME, REVIEWER_GROUP_NAME)
from course_discovery.apps.publisher.constants import (
PARTNER_MANAGER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME, PUBLISHER_GROUP_NAME, REVIEWER_GROUP_NAME
)
from course_discovery.apps.publisher.forms import CourseRunAdminForm
from course_discovery.apps.publisher.models import CourseRun, OrganizationExtension
from course_discovery.apps.publisher.tests import factories
......
......@@ -16,8 +16,9 @@ from course_discovery.apps.course_metadata.tests.factories import OrganizationFa
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.choices import CourseRunStateChoices, CourseStateChoices, PublisherUserRole
from course_discovery.apps.publisher.mixins import check_course_organization_permission
from course_discovery.apps.publisher.models import (Course, CourseUserRole, OrganizationExtension,
OrganizationUserRole, Seat)
from course_discovery.apps.publisher.models import (
Course, CourseUserRole, OrganizationExtension, OrganizationUserRole, Seat
)
from course_discovery.apps.publisher.tests import factories
......
......@@ -9,16 +9,18 @@ from guardian.shortcuts import assign_perm
from mock import Mock
from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.publisher.constants import (ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME,
PROJECT_COORDINATOR_GROUP_NAME, REVIEWER_GROUP_NAME)
from course_discovery.apps.publisher.mixins import (check_course_organization_permission, check_roles_access,
publisher_user_required)
from course_discovery.apps.publisher.constants import (
ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME, REVIEWER_GROUP_NAME
)
from course_discovery.apps.publisher.mixins import (
check_course_organization_permission, check_roles_access, publisher_user_required
)
from course_discovery.apps.publisher.models import OrganizationExtension
from course_discovery.apps.publisher.tests import factories
from course_discovery.apps.publisher.utils import (get_internal_users, has_role_for_course,
is_email_notification_enabled, is_internal_user,
is_project_coordinator_user, is_publisher_admin, is_publisher_user,
make_bread_crumbs, parse_datetime_field)
from course_discovery.apps.publisher.utils import (
get_internal_users, has_role_for_course, is_email_notification_enabled, is_internal_user,
is_project_coordinator_user, is_publisher_admin, is_publisher_user, make_bread_crumbs, parse_datetime_field
)
@ddt.ddt
......
......@@ -6,8 +6,9 @@ import ddt
from django.test import TestCase
from course_discovery.apps.course_metadata.choices import CourseRunPacing
from course_discovery.apps.course_metadata.tests.factories import (OrganizationFactory, PersonFactory,
PersonSocialNetworkFactory, PositionFactory)
from course_discovery.apps.course_metadata.tests.factories import (
OrganizationFactory, PersonFactory, PersonSocialNetworkFactory, PositionFactory
)
from course_discovery.apps.publisher.choices import CourseRunStateChoices, PublisherUserRole
from course_discovery.apps.publisher.models import Seat
from course_discovery.apps.publisher.tests import factories
......
""" Publisher Utils."""
import re
from dateutil import parser
from course_discovery.apps.core.models import User
from course_discovery.apps.publisher.constants import (ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME,
PROJECT_COORDINATOR_GROUP_NAME)
from course_discovery.apps.publisher.constants import (
ADMIN_GROUP_NAME, INTERNAL_USER_GROUP_NAME, PROJECT_COORDINATOR_GROUP_NAME
)
VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY = re.compile(r'^[a-zA-Z0-9._-]*$')
......
......@@ -32,8 +32,8 @@ from course_discovery.apps.publisher.forms import (
AdminImportCourseForm, CourseEntitlementForm, CourseForm, CourseRunForm, CourseSearchForm, SeatForm
)
from course_discovery.apps.publisher.models import (
Course, CourseEntitlement, CourseRun, CourseRunState, CourseState, CourseUserRole,
OrganizationExtension, PublisherUser, Seat, UserAttributes
Course, CourseEntitlement, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension,
PublisherUser, Seat, UserAttributes
)
from course_discovery.apps.publisher.utils import (
get_internal_users, has_role_for_course, is_internal_user, is_project_coordinator_user, is_publisher_admin,
......
import logging
import waffle
from django.db import models, transaction
from django.utils.translation import ugettext_lazy as _
......
......@@ -3,7 +3,6 @@ URLs for the course publisher comments views.
"""
from django.conf.urls import include, url
urlpatterns = [
url(r'^api/', include('course_discovery.apps.publisher_comments.api.urls', namespace='api')),
]
from course_discovery.settings.devstack import *
# noinspection PyUnresolvedReferences
from course_discovery.settings.shared.test import * # isort:skip
......
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