Commit a0ccf5e9 by Renzo Lucioni Committed by GitHub

Reduce data returned by programs list view (#366)

Re-introduces minimal serializers used to avoid expensive serialization of data not used by any clients.

ECOM-5791
parent acb7fdc9
from django.db import migrations
def create_switch(apps, schema_editor):
"""Create the reduced_program_data switch."""
Switch = apps.get_model('waffle', 'Switch')
Switch.objects.get_or_create(name='reduced_program_data', defaults={'active': False})
def delete_switch(apps, schema_editor):
"""Delete the reduced_program_data switch."""
Switch = apps.get_model('waffle', 'Switch')
Switch.objects.filter(name='reduced_program_data').delete()
class Migration(migrations.Migration):
dependencies = [
('waffle', '0001_initial'),
]
operations = [
migrations.RunPython(create_switch, reverse_code=delete_switch),
]
......@@ -267,7 +267,13 @@ class SeatSerializer(serializers.ModelSerializer):
fields = ('type', 'price', 'currency', 'upgrade_deadline', 'credit_provider', 'credit_hours',)
class OrganizationSerializer(TaggitSerializer, serializers.ModelSerializer):
class MinimalOrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = Organization
fields = ('uuid', 'key', 'name',)
class OrganizationSerializer(TaggitSerializer, MinimalOrganizationSerializer):
"""Serializer for the ``Organization`` model."""
tags = TagListSerializerField()
......@@ -275,9 +281,10 @@ class OrganizationSerializer(TaggitSerializer, serializers.ModelSerializer):
def prefetch_queryset(cls):
return Organization.objects.all().select_related('partner').prefetch_related('tags')
class Meta(object):
model = Organization
fields = ('key', 'name', 'description', 'homepage_url', 'tags', 'logo_image_url', 'marketing_url')
class Meta(MinimalOrganizationSerializer.Meta):
fields = MinimalOrganizationSerializer.Meta.fields + (
'description', 'homepage_url', 'tags', 'logo_image_url', 'marketing_url',
)
class CatalogSerializer(serializers.ModelSerializer):
......@@ -315,7 +322,27 @@ class NestedProgramSerializer(serializers.ModelSerializer):
read_only_fields = ('uuid', 'marketing_url',)
class CourseRunSerializer(TimestampModelSerializer):
class MinimalCourseRunSerializer(TimestampModelSerializer):
image = ImageField(read_only=True, source='card_image_url')
marketing_url = serializers.SerializerMethodField()
@classmethod
def prefetch_queryset(cls):
return CourseRun.objects.all().select_related('course').prefetch_related('course__partner')
class Meta:
model = CourseRun
fields = ('key', 'uuid', 'title', 'image', 'short_description', 'marketing_url',)
def get_marketing_url(self, obj):
return get_marketing_url_for_user(
self.context['request'].user,
obj.marketing_url,
exclude_utm=self.context.get('exclude_utm')
)
class CourseRunSerializer(MinimalCourseRunSerializer):
"""Serializer for the ``CourseRun`` model."""
course = serializers.SlugRelatedField(read_only=True, slug_field='key')
content_language = serializers.SlugRelatedField(
......@@ -323,39 +350,30 @@ class CourseRunSerializer(TimestampModelSerializer):
help_text=_('Language in which the course is administered')
)
transcript_languages = serializers.SlugRelatedField(many=True, read_only=True, slug_field='code')
image = ImageField(read_only=True, source='card_image_url')
video = VideoSerializer()
seats = SeatSerializer(many=True)
instructors = serializers.SerializerMethodField(help_text='This field is deprecated. Use staff.')
staff = PersonSerializer(many=True)
marketing_url = serializers.SerializerMethodField()
level_type = serializers.SlugRelatedField(read_only=True, slug_field='name')
@classmethod
def prefetch_queryset(cls):
return CourseRun.objects.all().select_related('course', 'language', 'video').prefetch_related(
queryset = super().prefetch_queryset()
return queryset.select_related('language', 'video').prefetch_related(
'transcript_languages',
Prefetch('seats', queryset=SeatSerializer.prefetch_queryset()),
Prefetch('staff', queryset=PersonSerializer.prefetch_queryset()),
)
class Meta:
model = CourseRun
fields = (
'course', 'key', 'title', 'short_description', 'full_description', 'start', 'end',
'enrollment_start', 'enrollment_end', 'announcement', 'image', 'video', 'seats',
'content_language', 'transcript_languages', 'instructors', 'staff',
'pacing_type', 'min_effort', 'max_effort', 'modified', 'marketing_url', 'level_type', 'availability',
)
def get_marketing_url(self, obj):
return get_marketing_url_for_user(
self.context['request'].user,
obj.marketing_url,
exclude_utm=self.context.get('exclude_utm')
class Meta(MinimalCourseRunSerializer.Meta):
fields = MinimalCourseRunSerializer.Meta.fields + (
'course', 'full_description', 'start', 'end', 'enrollment_start', 'enrollment_end', 'announcement',
'video', 'seats', 'content_language', 'transcript_languages', 'instructors', 'staff', 'pacing_type',
'min_effort', 'max_effort', 'modified', 'level_type', 'availability',
)
def get_instructors(self, obj): # pylint: disable=unused-argument
# This field is deprecated. Use the staff field.
return []
......@@ -382,13 +400,29 @@ class ContainedCourseRunsSerializer(serializers.Serializer):
)
class CourseSerializer(TimestampModelSerializer):
class MinimalCourseSerializer(TimestampModelSerializer):
course_runs = MinimalCourseRunSerializer(many=True)
owners = MinimalOrganizationSerializer(many=True, source='authoring_organizations')
image = ImageField(read_only=True, source='card_image_url')
@classmethod
def prefetch_queryset(cls):
return Course.objects.all().select_related('partner').prefetch_related(
'authoring_organizations',
Prefetch('course_runs', queryset=MinimalCourseRunSerializer.prefetch_queryset()),
)
class Meta:
model = Course
fields = ('key', 'uuid', 'title', 'course_runs', 'owners', 'image',)
class CourseSerializer(MinimalCourseSerializer):
"""Serializer for the ``Course`` model."""
level_type = serializers.SlugRelatedField(read_only=True, slug_field='name')
subjects = SubjectSerializer(many=True)
prerequisites = PrerequisiteSerializer(many=True)
expected_learning_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value')
image = ImageField(read_only=True, source='card_image_url')
video = VideoSerializer()
owners = OrganizationSerializer(many=True, source='authoring_organizations')
sponsors = OrganizationSerializer(many=True, source='sponsoring_organizations')
......@@ -406,12 +440,11 @@ class CourseSerializer(TimestampModelSerializer):
Prefetch('sponsoring_organizations', queryset=OrganizationSerializer.prefetch_queryset()),
)
class Meta:
class Meta(MinimalCourseSerializer.Meta):
model = Course
fields = (
'key', 'title', 'short_description', 'full_description', 'level_type', 'subjects', 'prerequisites',
'expected_learning_items', 'image', 'video', 'owners', 'sponsors', 'modified', 'course_runs',
'marketing_url',
fields = MinimalCourseSerializer.Meta.fields + (
'short_description', 'full_description', 'level_type', 'subjects', 'prerequisites',
'expected_learning_items', 'video', 'sponsors', 'modified', 'marketing_url',
)
def get_marketing_url(self, obj):
......@@ -447,8 +480,13 @@ class ContainedCoursesSerializer(serializers.Serializer):
)
class ProgramCourseSerializer(CourseSerializer):
"""Serializer used to filter out excluded course runs in a course associated with the program"""
class MinimalProgramCourseSerializer(MinimalCourseSerializer):
"""
Serializer used to filter out excluded course runs in a course associated with the program.
Notes:
This is shared by both MinimalProgramSerializer and ProgramSerializer!
"""
course_runs = serializers.SerializerMethodField()
def get_course_runs(self, course):
......@@ -458,7 +496,7 @@ class ProgramCourseSerializer(CourseSerializer):
if self.context.get('published_course_runs_only'):
course_runs = [course_run for course_run in course_runs if course_run.status == CourseRunStatus.Published]
return CourseRunSerializer(
return MinimalCourseRunSerializer(
course_runs,
many=True,
context={
......@@ -468,61 +506,39 @@ class ProgramCourseSerializer(CourseSerializer):
).data
class ProgramSerializer(serializers.ModelSerializer):
class MinimalProgramSerializer(serializers.ModelSerializer):
authoring_organizations = MinimalOrganizationSerializer(many=True)
banner_image = StdImageSerializerField()
courses = serializers.SerializerMethodField()
authoring_organizations = OrganizationSerializer(many=True)
type = serializers.SlugRelatedField(slug_field='name', queryset=ProgramType.objects.all())
banner_image = StdImageSerializerField()
video = VideoSerializer()
expected_learning_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value')
faq = FAQSerializer(many=True)
credit_backing_organizations = OrganizationSerializer(many=True)
corporate_endorsements = CorporateEndorsementSerializer(many=True)
job_outlook_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value')
individual_endorsements = EndorsementSerializer(many=True)
languages = serializers.SlugRelatedField(
many=True, read_only=True, slug_field='code',
help_text=_('Languages that course runs in this program are offered in.'),
)
transcript_languages = serializers.SlugRelatedField(
many=True, read_only=True, slug_field='code',
help_text=_('Languages that course runs in this program have available transcripts in.'),
)
subjects = SubjectSerializer(many=True)
staff = PersonSerializer(many=True)
@classmethod
def prefetch_queryset(cls):
"""
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
chain of related fields from programs to course runs (i.e., we want control over
the querysets that we're prefetching).
"""
return Program.objects.all().select_related('type', 'video', 'partner').prefetch_related(
return Program.objects.all().select_related('type', 'partner').prefetch_related(
'excluded_course_runs',
'expected_learning_items',
'faq',
'job_outlook_items',
# `type` is serialized by a third-party serializer. Providing this field name allows us to
# prefetch `applicable_seat_types`, a m2m on `ProgramType`, through `type`, a foreign key to
# `ProgramType` on `Program`.
'type__applicable_seat_types',
Prefetch('courses', queryset=ProgramCourseSerializer.prefetch_queryset()),
Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset()),
Prefetch('credit_backing_organizations', queryset=OrganizationSerializer.prefetch_queryset()),
Prefetch('corporate_endorsements', queryset=CorporateEndorsementSerializer.prefetch_queryset()),
Prefetch('individual_endorsements', queryset=EndorsementSerializer.prefetch_queryset()),
'authoring_organizations',
Prefetch('courses', queryset=MinimalProgramCourseSerializer.prefetch_queryset()),
)
class Meta:
model = Program
fields = (
'uuid', 'title', 'subtitle', 'type', 'status', 'marketing_slug', 'marketing_url', 'banner_image',
'courses', 'authoring_organizations', 'card_image_url',
)
read_only_fields = ('uuid', 'marketing_url', 'banner_image')
def get_courses(self, program):
if program.order_courses_by_start_date:
courses, course_runs = self.sort_courses(program)
else:
courses, course_runs = program.courses.all(), program.course_runs
course_serializer = ProgramCourseSerializer(
course_serializer = MinimalProgramCourseSerializer(
courses,
many=True,
context={
......@@ -591,17 +607,62 @@ class ProgramSerializer(serializers.ModelSerializer):
return courses, course_runs
class Meta:
class ProgramSerializer(MinimalProgramSerializer):
authoring_organizations = OrganizationSerializer(many=True)
video = VideoSerializer()
expected_learning_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value')
faq = FAQSerializer(many=True)
credit_backing_organizations = OrganizationSerializer(many=True)
corporate_endorsements = CorporateEndorsementSerializer(many=True)
job_outlook_items = serializers.SlugRelatedField(many=True, read_only=True, slug_field='value')
individual_endorsements = EndorsementSerializer(many=True)
languages = serializers.SlugRelatedField(
many=True, read_only=True, slug_field='code',
help_text=_('Languages that course runs in this program are offered in.'),
)
transcript_languages = serializers.SlugRelatedField(
many=True, read_only=True, slug_field='code',
help_text=_('Languages that course runs in this program have available transcripts in.'),
)
subjects = SubjectSerializer(many=True)
staff = PersonSerializer(many=True)
@classmethod
def prefetch_queryset(cls):
"""
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
chain of related fields from programs to course runs (i.e., we want control over
the querysets that we're prefetching).
"""
return Program.objects.all().select_related('type', 'video', 'partner').prefetch_related(
'excluded_course_runs',
'expected_learning_items',
'faq',
'job_outlook_items',
# `type` is serialized by a third-party serializer. Providing this field name allows us to
# prefetch `applicable_seat_types`, a m2m on `ProgramType`, through `type`, a foreign key to
# `ProgramType` on `Program`.
'type__applicable_seat_types',
# We need the full Course prefetch here to get CourseRun information that methods on the Program
# model iterate across (e.g. language). These fields aren't prefetched by the minimal Course serializer.
Prefetch('courses', queryset=CourseSerializer.prefetch_queryset()),
Prefetch('authoring_organizations', queryset=OrganizationSerializer.prefetch_queryset()),
Prefetch('credit_backing_organizations', queryset=OrganizationSerializer.prefetch_queryset()),
Prefetch('corporate_endorsements', queryset=CorporateEndorsementSerializer.prefetch_queryset()),
Prefetch('individual_endorsements', queryset=EndorsementSerializer.prefetch_queryset()),
)
class Meta(MinimalProgramSerializer.Meta):
model = Program
fields = (
'uuid', 'title', 'subtitle', 'type', 'status', 'marketing_slug', 'marketing_url', 'courses',
'overview', 'weeks_to_complete', 'min_hours_effort_per_week', 'max_hours_effort_per_week',
'authoring_organizations', 'banner_image', 'banner_image_url', 'card_image_url', 'video',
fields = MinimalProgramSerializer.Meta.fields + (
'overview', 'weeks_to_complete', 'min_hours_effort_per_week', 'max_hours_effort_per_week', 'video',
'expected_learning_items', 'faq', 'credit_backing_organizations', 'corporate_endorsements',
'job_outlook_items', 'individual_endorsements', 'languages', 'transcript_languages', 'subjects',
'price_ranges', 'staff', 'credit_redemption_overview',
)
read_only_fields = ('uuid', 'marketing_url', 'banner_image')
class AffiliateWindowSerializer(serializers.ModelSerializer):
......
# pylint: disable=no-member, test-inherits-tests
from datetime import datetime
from urllib.parse import urlencode
......@@ -9,12 +10,14 @@ from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseSerializer, CourseRunSerializer, ContainedCoursesSerializer, ImageSerializer,
CatalogSerializer, CourseRunSerializer, ContainedCoursesSerializer, ImageSerializer,
SubjectSerializer, PrerequisiteSerializer, VideoSerializer, OrganizationSerializer, SeatSerializer,
PersonSerializer, AffiliateWindowSerializer, ContainedCourseRunsSerializer, CourseRunSearchSerializer,
ProgramSerializer, ProgramSearchSerializer, ProgramCourseSerializer, NestedProgramSerializer,
ProgramSerializer, ProgramSearchSerializer, MinimalProgramCourseSerializer, NestedProgramSerializer,
CourseRunWithProgramsSerializer, CourseWithProgramsSerializer, CorporateEndorsementSerializer,
FAQSerializer, EndorsementSerializer, PositionSerializer, FlattenedCourseRunWithCourseSerializer
FAQSerializer, EndorsementSerializer, PositionSerializer, FlattenedCourseRunWithCourseSerializer,
MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalCourseRunSerializer, MinimalProgramSerializer,
CourseSerializer
)
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.models import User
......@@ -30,8 +33,6 @@ from course_discovery.apps.course_metadata.tests.factories import (
from course_discovery.apps.ietf_language_tags.models import LanguageTag
# pylint:disable=no-member
def json_date_format(datetime_obj):
return datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ")
......@@ -92,31 +93,46 @@ class CatalogSerializerTests(TestCase):
self.assertEqual(User.objects.filter(username=username).count(), 0) # pylint: disable=no-member
class CourseSerializerTests(TestCase):
def test_data(self):
course = CourseFactory()
video = course.video
request = make_request()
class MinimalCourseSerializerTests(TestCase):
serializer_class = MinimalCourseSerializer
CourseRunFactory.create_batch(3, course=course)
serializer = CourseWithProgramsSerializer(course, context={'request': request})
def get_expected_data(self, course, request):
context = {'request': request}
expected = {
return {
'key': course.key,
'uuid': str(course.uuid),
'title': course.title,
'course_runs': MinimalCourseRunSerializer(course.course_runs, many=True, context=context).data,
'owners': MinimalOrganizationSerializer(course.authoring_organizations, many=True, context=context).data,
'image': ImageField().to_representation(course.card_image_url),
}
def test_data(self):
request = make_request()
organizations = OrganizationFactory()
course = CourseFactory(authoring_organizations=[organizations])
CourseRunFactory.create_batch(2, course=course)
serializer = self.serializer_class(course, context={'request': request})
expected = self.get_expected_data(course, request)
self.assertDictEqual(serializer.data, expected)
class CourseSerializerTests(MinimalCourseSerializerTests):
serializer_class = CourseSerializer
def get_expected_data(self, course, request):
expected = super().get_expected_data(course, request)
expected.update({
'short_description': course.short_description,
'full_description': course.full_description,
'level_type': course.level_type.name,
'subjects': [],
'prerequisites': [],
'expected_learning_items': [],
'image': ImageField().to_representation(course.card_image_url),
'video': VideoSerializer(video).data,
'owners': OrganizationSerializer(course.authoring_organizations, many=True).data,
'video': VideoSerializer(course.video).data,
'sponsors': OrganizationSerializer(course.sponsoring_organizations, many=True).data,
'modified': json_date_format(course.modified), # pylint: disable=no-member
'course_runs': CourseRunSerializer(course.course_runs, many=True, context={'request': request}).data,
'marketing_url': '{url}?{params}'.format(
url=course.marketing_url,
params=urlencode({
......@@ -124,42 +140,76 @@ class CourseSerializerTests(TestCase):
'utm_medium': request.user.referral_tracking_id,
})
),
'programs': NestedProgramSerializer(course.programs, many=True, context={'request': request}).data,
}
'course_runs': CourseRunSerializer(course.course_runs, many=True, context={'request': request}).data,
'owners': OrganizationSerializer(course.authoring_organizations, many=True).data,
})
self.assertDictEqual(serializer.data, expected)
return expected
def test_exclude_utm(self):
request = make_request()
course = CourseFactory()
CourseRunFactory.create_batch(3, course=course)
serializer = CourseWithProgramsSerializer(course, context={'request': request, 'exclude_utm': 1})
serializer = self.serializer_class(course, context={'request': request, 'exclude_utm': 1})
self.assertEqual(serializer.data['marketing_url'], course.marketing_url)
class CourseRunSerializerTests(TestCase):
class CourseWithProgramsSerializerTests(CourseSerializerTests):
serializer_class = CourseWithProgramsSerializer
def get_expected_data(self, course, request):
expected = super().get_expected_data(course, request)
expected.update({
'programs': NestedProgramSerializer(course.programs, many=True, context={'request': request}).data,
})
return expected
class MinimalCourseRunSerializerTests(TestCase):
serializer_class = MinimalCourseRunSerializer
def get_expected_data(self, course_run, request): # pylint: disable=unused-argument
return {
'key': course_run.key,
'uuid': str(course_run.uuid),
'title': course_run.title,
'short_description': course_run.short_description,
'image': ImageField().to_representation(course_run.card_image_url),
'marketing_url': '{url}?{params}'.format(
url=course_run.marketing_url,
params=urlencode({
'utm_source': request.user.username,
'utm_medium': request.user.referral_tracking_id,
})
),
}
def test_data(self):
request = make_request()
course_run = CourseRunFactory()
course = course_run.course
video = course_run.video
serializer = CourseRunSerializer(course_run, context={'request': request})
ProgramFactory(courses=[course])
serializer = self.serializer_class(course_run, context={'request': request})
expected = self.get_expected_data(course_run, request)
self.assertDictEqual(serializer.data, expected)
expected = {
class CourseRunSerializerTests(MinimalCourseRunSerializerTests):
serializer_class = CourseRunSerializer
def get_expected_data(self, course_run, request):
expected = super().get_expected_data(course_run, request)
expected.update({
'course': course_run.course.key,
'key': course_run.key,
'title': course_run.title, # pylint: disable=no-member
'short_description': course_run.short_description, # pylint: disable=no-member
'full_description': course_run.full_description, # pylint: disable=no-member
'start': json_date_format(course_run.start),
'end': json_date_format(course_run.end),
'enrollment_start': json_date_format(course_run.enrollment_start),
'enrollment_end': json_date_format(course_run.enrollment_end),
'announcement': json_date_format(course_run.announcement),
'image': ImageField().to_representation(course_run.card_image_url),
'video': VideoSerializer(video).data,
'video': VideoSerializer(course_run.video).data,
'pacing_type': course_run.pacing_type,
'content_language': course_run.language.code,
'transcript_languages': [],
......@@ -169,23 +219,16 @@ class CourseRunSerializerTests(TestCase):
'staff': [],
'seats': [],
'modified': json_date_format(course_run.modified), # pylint: disable=no-member
'marketing_url': '{url}?{params}'.format(
url=course_run.marketing_url,
params=urlencode({
'utm_source': request.user.username,
'utm_medium': request.user.referral_tracking_id,
})
),
'level_type': course_run.level_type.name,
'availability': course_run.availability,
}
})
self.assertDictEqual(serializer.data, expected)
return expected
def test_exclude_utm(self):
request = make_request()
course_run = CourseRunFactory()
serializer = CourseRunSerializer(course_run, context={'request': request, 'exclude_utm': 1})
serializer = self.serializer_class(course_run, context={'request': request, 'exclude_utm': 1})
self.assertEqual(serializer.data['marketing_url'], course_run.marketing_url)
......@@ -313,15 +356,15 @@ class FlattenedCourseRunWithCourseSerializerTests(TestCase): # pragma: no cover
self.assertDictEqual(serializer.data, expected)
class ProgramCourseSerializerTests(TestCase):
class MinimalProgramCourseSerializerTests(TestCase):
def setUp(self):
super(ProgramCourseSerializerTests, self).setUp()
super(MinimalProgramCourseSerializerTests, self).setUp()
self.program = ProgramFactory(courses=[CourseFactory()])
def assert_program_courses_serialized(self, program):
request = make_request()
serializer = ProgramCourseSerializer(
serializer = MinimalProgramCourseSerializer(
program.courses,
many=True,
context={
......@@ -330,7 +373,7 @@ class ProgramCourseSerializerTests(TestCase):
'course_runs': program.course_runs
}
)
expected = CourseSerializer(program.courses, many=True, context={'request': request}).data
expected = MinimalCourseSerializer(program.courses, many=True, context={'request': request}).data
self.assertSequenceEqual(serializer.data, expected)
def test_data(self):
......@@ -357,11 +400,11 @@ class ProgramCourseSerializerTests(TestCase):
program = ProgramFactory(courses=[course], excluded_course_runs=excluded_runs)
serializer_context = {'request': request, 'program': program, 'course_runs': program.course_runs}
serializer = ProgramCourseSerializer(course, context=serializer_context)
serializer = MinimalProgramCourseSerializer(course, context=serializer_context)
expected = CourseSerializer(course, context=serializer_context).data
expected['course_runs'] = CourseRunSerializer([course_runs[1]], many=True,
context={'request': request}).data
expected = MinimalCourseSerializer(course, context=serializer_context).data
expected['course_runs'] = MinimalCourseRunSerializer(
[course_runs[1]], many=True, context={'request': request}).data
self.assertDictEqual(serializer.data, expected)
def test_with_published_course_runs_only_context(self):
......@@ -374,12 +417,12 @@ class ProgramCourseSerializerTests(TestCase):
CourseRunFactory(status=CourseRunStatus.Published, course=course)
# We do NOT expect the results to included the unpublished data
expected = CourseSerializer(course, context={'request': request}).data
expected = MinimalCourseSerializer(course, context={'request': request}).data
expected['course_runs'] = [course_run for course_run in expected['course_runs'] if
course_run['key'] != str(unpublished_course_run.key)]
self.assertEqual(len(expected['course_runs']), 1)
serializer = ProgramCourseSerializer(
serializer = MinimalProgramCourseSerializer(
course,
context={
'request': request,
......@@ -392,16 +435,19 @@ class ProgramCourseSerializerTests(TestCase):
self.assertSequenceEqual(serializer.data, expected)
class ProgramSerializerTests(TestCase):
class MinimalProgramSerializerTests(TestCase):
serializer_class = MinimalProgramSerializer
def create_program(self):
organizations = [OrganizationFactory()]
organizations = OrganizationFactory.create_batch(2)
person = PersonFactory()
course = CourseFactory()
CourseRunFactory(course=course, staff=[person])
courses = CourseFactory.create_batch(3)
for course in courses:
CourseRunFactory.create_batch(2, course=course, staff=[person], start=datetime.now())
program = ProgramFactory(
courses=[course],
return ProgramFactory(
courses=courses,
authoring_organizations=organizations,
credit_backing_organizations=organizations,
corporate_endorsements=CorporateEndorsementFactory.create_batch(1),
......@@ -409,9 +455,9 @@ class ProgramSerializerTests(TestCase):
expected_learning_items=ExpectedLearningItemFactory.create_batch(1),
job_outlook_items=JobOutlookItemFactory.create_batch(1),
banner_image=make_image_file('test_banner.jpg'),
video=VideoFactory()
video=VideoFactory(),
order_courses_by_start_date=False,
)
return program
def get_expected_data(self, program, request):
image_field = StdImageSerializerField()
......@@ -426,8 +472,7 @@ class ProgramSerializerTests(TestCase):
'marketing_slug': program.marketing_slug,
'marketing_url': program.marketing_url,
'banner_image': image_field.to_representation(program.banner_image),
'banner_image_url': program.banner_image_url,
'courses': ProgramCourseSerializer(
'courses': MinimalProgramCourseSerializer(
program.courses,
many=True,
context={
......@@ -435,8 +480,25 @@ class ProgramSerializerTests(TestCase):
'program': program,
'course_runs': program.course_runs,
}).data,
'authoring_organizations': OrganizationSerializer(program.authoring_organizations, many=True).data,
'authoring_organizations': MinimalOrganizationSerializer(program.authoring_organizations, many=True).data,
'card_image_url': program.card_image_url,
}
def test_data(self):
request = make_request()
program = self.create_program()
serializer = self.serializer_class(program, context={'request': request})
expected = self.get_expected_data(program, request)
self.assertDictEqual(serializer.data, expected)
class ProgramSerializerTests(MinimalProgramSerializerTests):
serializer_class = ProgramSerializer
def get_expected_data(self, 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,
'credit_redemption_overview': program.credit_redemption_overview,
'corporate_endorsements': CorporateEndorsementSerializer(program.corporate_endorsements, many=True).data,
......@@ -457,23 +519,9 @@ class ProgramSerializerTests(TestCase):
'price_ranges': program.price_ranges,
'subjects': SubjectSerializer(program.subjects, many=True).data,
'transcript_languages': [serialize_language_to_code(l) for l in program.transcript_languages],
}
def test_data(self):
request = make_request()
program = self.create_program()
serializer = ProgramSerializer(program, context={'request': request})
expected = self.get_expected_data(program, request)
self.assertDictEqual(dict(serializer.data), expected)
})
def test_data_without_course_sorting(self):
request = make_request()
program = self.create_program()
program.order_courses_by_start_date = False
program.save()
serializer = ProgramSerializer(program, context={'request': request})
expected = self.get_expected_data(program, request)
self.assertDictEqual(dict(serializer.data), expected)
return expected
def test_data_with_exclusions(self):
"""
......@@ -487,7 +535,146 @@ class ProgramSerializerTests(TestCase):
program.excluded_course_runs.add(excluded_course_run)
expected = self.get_expected_data(program, request)
serializer = ProgramSerializer(program, context={'request': request})
serializer = self.serializer_class(program, context={'request': request})
self.assertDictEqual(serializer.data, expected)
def test_course_ordering(self):
"""
Verify that courses in a program are ordered by ascending run start date,
with ties broken by earliest run enrollment start date.
"""
request = make_request()
course_list = CourseFactory.create_batch(3)
# Create a course run with arbitrary start and empty enrollment_start.
CourseRunFactory(
course=course_list[2],
enrollment_start=None,
start=datetime(2014, 2, 1),
)
# Create a second run with matching start, but later enrollment_start.
CourseRunFactory(
course=course_list[1],
enrollment_start=datetime(2014, 1, 2),
start=datetime(2014, 2, 1),
)
# Create a third run with later start and enrollment_start.
CourseRunFactory(
course=course_list[0],
enrollment_start=datetime(2014, 2, 1),
start=datetime(2014, 3, 1),
)
program = ProgramFactory(courses=course_list)
serializer = self.serializer_class(program, context={'request': request})
expected = MinimalProgramCourseSerializer(
# The expected ordering is the reverse of course_list.
course_list[::-1],
many=True,
context={'request': request, 'program': program, 'course_runs': list(program.course_runs)}
).data
self.assertEqual(serializer.data['courses'], expected)
def test_course_ordering_with_exclusions(self):
"""
Verify that excluded course runs aren't used when ordering courses.
"""
request = make_request()
course_list = CourseFactory.create_batch(3)
# Create a course run with arbitrary start and empty enrollment_start.
# This run will be excluded from the program. If it wasn't excluded,
# the expected course ordering, by index, would be: 0, 2, 1.
excluded_run = CourseRunFactory(
course=course_list[0],
enrollment_start=None,
start=datetime(2014, 1, 1),
)
# Create a run with later start and empty enrollment_start.
CourseRunFactory(
course=course_list[2],
enrollment_start=None,
start=datetime(2014, 2, 1),
)
# Create a run with matching start, but later enrollment_start.
CourseRunFactory(
course=course_list[1],
enrollment_start=datetime(2014, 1, 2),
start=datetime(2014, 2, 1),
)
# Create a run with later start and enrollment_start.
CourseRunFactory(
course=course_list[0],
enrollment_start=datetime(2014, 2, 1),
start=datetime(2014, 3, 1),
)
program = ProgramFactory(courses=course_list, excluded_course_runs=[excluded_run])
serializer = self.serializer_class(program, context={'request': request})
expected = MinimalProgramCourseSerializer(
# The expected ordering is the reverse of course_list.
course_list[::-1],
many=True,
context={'request': request, 'program': program, 'course_runs': list(program.course_runs)}
).data
self.assertEqual(serializer.data['courses'], expected)
def test_course_ordering_with_no_start(self):
"""
Verify that a courses run with missing start date appears last when ordering courses.
"""
request = make_request()
course_list = CourseFactory.create_batch(3)
# Create a course run with arbitrary start and empty enrollment_start.
CourseRunFactory(
course=course_list[2],
enrollment_start=None,
start=datetime(2014, 2, 1),
)
# Create a second run with matching start, but later enrollment_start.
CourseRunFactory(
course=course_list[1],
enrollment_start=datetime(2014, 1, 2),
start=datetime(2014, 2, 1),
)
# Create a third run with empty start and enrollment_start.
CourseRunFactory(
course=course_list[0],
enrollment_start=None,
start=None,
)
program = ProgramFactory(courses=course_list)
serializer = self.serializer_class(program, context={'request': request})
expected = MinimalProgramCourseSerializer(
# The expected ordering is the reverse of course_list.
course_list[::-1],
many=True,
context={'request': request, 'program': program, 'course_runs': list(program.course_runs)}
).data
self.assertEqual(serializer.data['courses'], expected)
def test_data_without_course_sorting(self):
request = make_request()
program = self.create_program()
program.order_courses_by_start_date = False
program.save()
serializer = self.serializer_class(program, context={'request': request})
expected = self.get_expected_data(program, request)
self.assertDictEqual(serializer.data, expected)
......@@ -613,24 +800,46 @@ class VideoSerializerTests(TestCase):
self.assertDictEqual(serializer.data, expected)
class OrganizationSerializerTests(TestCase):
def test_data(self):
organization = OrganizationFactory()
TAG = 'test'
organization.tags.add(TAG)
serializer = OrganizationSerializer(organization)
class MinimalOrganizationSerializerTests(TestCase):
serializer_class = MinimalOrganizationSerializer
expected = {
def create_organization(self):
return OrganizationFactory()
def get_expected_data(self, organization):
return {
'uuid': str(organization.uuid),
'key': organization.key,
'name': organization.name,
}
def test_data(self):
organization = self.create_organization()
serializer = self.serializer_class(organization)
expected = self.get_expected_data(organization)
self.assertDictEqual(serializer.data, expected)
class OrganizationSerializerTests(MinimalOrganizationSerializerTests):
TAG = 'test-tag'
serializer_class = OrganizationSerializer
def create_organization(self):
organization = super().create_organization()
organization.tags.add(self.TAG)
return organization
def get_expected_data(self, organization):
expected = super().get_expected_data(organization)
expected.update({
'description': organization.description,
'homepage_url': organization.homepage_url,
'logo_image_url': organization.logo_image_url,
'tags': [TAG],
'tags': [self.TAG],
'marketing_url': organization.marketing_url,
}
})
self.assertDictEqual(serializer.data, expected)
return expected
class SeatSerializerTests(TestCase):
......
......@@ -2,11 +2,14 @@ import ddt
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase, APIRequestFactory
from course_discovery.apps.api.serializers import MinimalProgramSerializer, ProgramSerializer
from course_discovery.apps.api.v1.views import ProgramViewSet
from course_discovery.apps.api.v1.tests.test_views.mixins import SerializationMixin
from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory
from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Program
from course_discovery.apps.course_metadata.tests import toggle_switch
from course_discovery.apps.course_metadata.tests.factories import (
CourseFactory, CourseRunFactory, VideoFactory, OrganizationFactory, PersonFactory, ProgramFactory,
CorporateEndorsementFactory, EndorsementFactory, JobOutlookItemFactory, ExpectedLearningItemFactory
......@@ -64,20 +67,17 @@ class ProgramViewSetTests(SerializationMixin, APITestCase):
def test_retrieve(self):
""" Verify the endpoint returns the details for a single program. """
program = self.create_program()
with self.assertNumQueries(89):
with self.assertNumQueries(72):
self.assert_retrieve_success(program)
@ddt.data(
(True),
(False),
)
def test_retrieve_with_sorting_flag(self, order_courses_by_start_date=True):
@ddt.data(True, False)
def test_retrieve_with_sorting_flag(self, order_courses_by_start_date):
""" Verify the number of queries is the same with sorting flag set to true. """
course_list = CourseFactory.create_batch(3)
for course in course_list:
CourseRunFactory(course=course)
program = ProgramFactory(courses=course_list, order_courses_by_start_date=order_courses_by_start_date)
num_queries = 132 if order_courses_by_start_date else 114
num_queries = 82 if order_courses_by_start_date else 80
with self.assertNumQueries(num_queries):
self.assert_retrieve_success(program)
self.assertEqual(course_list, list(program.courses.all())) # pylint: disable=no-member
......@@ -86,7 +86,7 @@ class ProgramViewSetTests(SerializationMixin, APITestCase):
""" Verify the endpoint returns data for a program even if the program's courses have no course runs. """
course = CourseFactory()
program = ProgramFactory(courses=[course])
with self.assertNumQueries(55):
with self.assertNumQueries(46):
self.assert_retrieve_success(program)
def assert_list_results(self, url, expected, expected_query_count, extra_context=None):
......@@ -115,7 +115,7 @@ class ProgramViewSetTests(SerializationMixin, APITestCase):
""" Verify the endpoint returns a list of all programs. """
expected = [self.create_program() for __ in range(3)]
expected.reverse()
self.assert_list_results(self.list_path, expected, 41)
self.assert_list_results(self.list_path, expected, 36)
def test_filter_by_type(self):
""" Verify that the endpoint filters programs to those of a given type. """
......@@ -159,4 +159,17 @@ class ProgramViewSetTests(SerializationMixin, APITestCase):
""" Verify the endpoint returns marketing URLs without UTM parameters. """
url = self.list_path + '?exclude_utm=1'
program = self.create_program()
self.assert_list_results(url, [program], 33, extra_context={'exclude_utm': 1})
self.assert_list_results(url, [program], 32, extra_context={'exclude_utm': 1})
@ddt.data(True, False)
def test_minimal_serializer_use(self, is_minimal):
""" Verify that the list view uses the minimal serializer. """
toggle_switch('reduced_program_data', active=is_minimal)
action_serializer_mapping = {
'list': MinimalProgramSerializer if is_minimal else ProgramSerializer,
'detail': ProgramSerializer,
}
for action, serializer in action_serializer_mapping.items():
self.assertEqual(ProgramViewSet(action=action).get_serializer_class(), serializer)
......@@ -22,6 +22,7 @@ from rest_framework.exceptions import PermissionDenied, ParseError
from rest_framework.filters import DjangoFilterBackend
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
import waffle
from course_discovery.apps.api import filters
from course_discovery.apps.api import serializers
......@@ -415,14 +416,19 @@ class ProgramViewSet(viewsets.ReadOnlyModelViewSet):
lookup_field = 'uuid'
lookup_value_regex = '[0-9a-f-]+'
permission_classes = (IsAuthenticated,)
serializer_class = serializers.ProgramSerializer
filter_backends = (DjangoFilterBackend,)
filter_class = filters.ProgramFilter
def get_serializer_class(self):
if self.action == 'list' and waffle.switch_is_active('reduced_program_data'):
return serializers.MinimalProgramSerializer
return serializers.ProgramSerializer
def get_queryset(self):
# This method prevents prefetches on the program queryset from "stacking,"
# which happens when the queryset is stored in a class property.
return self.serializer_class.prefetch_queryset()
return self.get_serializer_class().prefetch_queryset()
def get_serializer_context(self, *args, **kwargs):
context = super().get_serializer_context(*args, **kwargs)
......
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