Commit e5452779 by Renzo Lucioni

Courses appearing in catalogs must be available

A course is considered to be "available" if it contains at least one course run that can be enrolled in immediately, is ongoing or yet to start, and appears on the marketing site. The catalog API should only list courses which are available. An earlier attempt at this fell short because it filtered courses to those which contained at least one active *or* marketable run.

ECOM-6473
parent fd41db07
...@@ -477,8 +477,12 @@ class MinimalCourseSerializer(TimestampModelSerializer): ...@@ -477,8 +477,12 @@ class MinimalCourseSerializer(TimestampModelSerializer):
image = ImageField(read_only=True, source='card_image_url') image = ImageField(read_only=True, source='card_image_url')
@classmethod @classmethod
def prefetch_queryset(cls): def prefetch_queryset(cls, queryset=None):
return Course.objects.all().select_related('partner').prefetch_related( # Explicitly check for None to avoid returning all Courses when the
# queryset passed in happens to be empty.
queryset = queryset if queryset is not None else Course.objects.all()
return queryset.select_related('partner').prefetch_related(
'authoring_organizations', 'authoring_organizations',
Prefetch('course_runs', queryset=MinimalCourseRunSerializer.prefetch_queryset()), Prefetch('course_runs', queryset=MinimalCourseRunSerializer.prefetch_queryset()),
) )
...@@ -501,8 +505,12 @@ class CourseSerializer(MinimalCourseSerializer): ...@@ -501,8 +505,12 @@ class CourseSerializer(MinimalCourseSerializer):
marketing_url = serializers.SerializerMethodField() marketing_url = serializers.SerializerMethodField()
@classmethod @classmethod
def prefetch_queryset(cls): def prefetch_queryset(cls, queryset=None):
return Course.objects.all().select_related('level_type', 'video', 'partner').prefetch_related( # Explicitly check for None to avoid returning all Courses when the
# queryset passed in happens to be empty.
queryset = queryset if queryset is not None else Course.objects.all()
return queryset.select_related('level_type', 'video', 'partner').prefetch_related(
'expected_learning_items', 'expected_learning_items',
'prerequisites', 'prerequisites',
'subjects', 'subjects',
...@@ -581,12 +589,23 @@ class CourseWithProgramsSerializer(CourseSerializer): ...@@ -581,12 +589,23 @@ class CourseWithProgramsSerializer(CourseSerializer):
fields = CourseSerializer.Meta.fields + ('programs',) fields = CourseSerializer.Meta.fields + ('programs',)
class CourseSerializerExcludingClosedRuns(CourseSerializer): class CatalogCourseSerializer(CourseSerializer):
"""A ``CourseSerializer`` which only includes active course runs, as determined by ``CourseQuerySet``.""" """
A CourseSerializer which only includes course runs that can be enrolled in
immediately, are ongoing or yet to start, and appear on the marketing site
(i.e., sellable runs that should appear in a catalog distributed to affiliates).
"""
course_runs = serializers.SerializerMethodField() course_runs = serializers.SerializerMethodField()
def get_course_runs(self, course): def get_course_runs(self, course):
return CourseRunSerializer(course.course_runs.active().marketable(), many=True, context=self.context).data return CourseRunSerializer(
# TODO: These queryset methods chain filter() and exclude() calls,
# causing prefetched results to be discarded. They should be replaced
# with Python-based filtering that preserves the prefetched data.
course.course_runs.active().enrollable().marketable(),
many=True,
context=self.context
).data
class ContainedCoursesSerializer(serializers.Serializer): class ContainedCoursesSerializer(serializers.Serializer):
......
...@@ -7,7 +7,7 @@ from django.conf import settings ...@@ -7,7 +7,7 @@ from django.conf import settings
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.serializers import ( from course_discovery.apps.api.serializers import (
CatalogSerializer, CourseRunWithProgramsSerializer, CourseSerializerExcludingClosedRuns, CatalogCourseSerializer, CatalogSerializer, CourseRunWithProgramsSerializer,
CourseWithProgramsSerializer, FlattenedCourseRunWithCourseSerializer, MinimalProgramSerializer, CourseWithProgramsSerializer, FlattenedCourseRunWithCourseSerializer, MinimalProgramSerializer,
OrganizationSerializer, PersonSerializer, ProgramSerializer, ProgramTypeSerializer OrganizationSerializer, PersonSerializer, ProgramSerializer, ProgramTypeSerializer
) )
...@@ -57,7 +57,7 @@ class SerializationMixin(object): ...@@ -57,7 +57,7 @@ class SerializationMixin(object):
return self._serialize_object(ProgramTypeSerializer, program_type, many, format, extra_context) return self._serialize_object(ProgramTypeSerializer, program_type, many, format, extra_context)
def serialize_catalog_course(self, course, many=False, format=None, extra_context=None): def serialize_catalog_course(self, course, many=False, format=None, extra_context=None):
return self._serialize_object(CourseSerializerExcludingClosedRuns, course, many, format, extra_context) return self._serialize_object(CatalogCourseSerializer, course, many, format, extra_context)
def serialize_catalog_flat_course_run(self, course_run, many=False, format=None, extra_context=None): 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(FlattenedCourseRunWithCourseSerializer, course_run, many, format, extra_context)
......
...@@ -3,6 +3,7 @@ import datetime ...@@ -3,6 +3,7 @@ import datetime
import urllib import urllib
import ddt import ddt
import pytest
import pytz import pytz
import responses import responses
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
...@@ -15,12 +16,14 @@ from course_discovery.apps.catalogs.models import Catalog ...@@ -15,12 +16,14 @@ from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.catalogs.tests.factories import CatalogFactory from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.core.tests.factories import UserFactory
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.models import Course
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, SeatFactory from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, SeatFactory
User = get_user_model() User = get_user_model()
@ddt.ddt @ddt.ddt
@pytest.mark.usefixtures('course_run_states')
class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixin, APITestCase): class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixin, APITestCase):
""" Tests for the catalog resource. """ """ Tests for the catalog resource. """
catalog_list_url = reverse('api:v1:catalog-list') catalog_list_url = reverse('api:v1:catalog-list')
...@@ -130,8 +133,8 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi ...@@ -130,8 +133,8 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi
catalog = Catalog.objects.latest() catalog = Catalog.objects.latest()
latest_user = User.objects.latest() latest_user = User.objects.latest()
self.assertEqual(latest_user.username, new_viewer_username) assert latest_user.username == new_viewer_username
self.assertListEqual(list(catalog.viewers), [existing_viewer, latest_user]) assert set(catalog.viewers) == {existing_viewer, latest_user}
def test_create_with_new_user_error(self): def test_create_with_new_user_error(self):
""" Verify no users are created if an error occurs while processing a create request. """ """ Verify no users are created if an error occurs while processing a create request. """
...@@ -146,44 +149,43 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi ...@@ -146,44 +149,43 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, OAuth2Mixi
self.assertEqual(User.objects.count(), original_user_count) self.assertEqual(User.objects.count(), original_user_count)
def test_courses(self): def test_courses(self):
""" Verify the endpoint returns the list of courses contained in the catalog. """ """
Verify the endpoint returns the list of available courses contained in
the catalog, and that courses appearing in the response always have at
least one serialized run.
"""
url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id}) url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id})
# This run is published, has seats, and has a valid slug. We expect its for state in self.states():
# parent course to be included in the response. Course.objects.all().delete()
SeatFactory(course_run=self.course_run)
included_courses = [self.course]
# These courses/course runs should not be returned because they are no longer open for enrollment.
past = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=30)
excluded_runs = [
# Despite the enrollment end date for this run being in the past, we
# still expect the parent course to be in the response. It had an active
# run associated with it above.
CourseRunFactory(enrollment_end=past, course=self.course),
# The course associated with this run is included in the catalog query,
# but the run is not active, so we don't expect to see the associated
# course in the response.
CourseRunFactory(enrollment_end=past, course__title='ABC Test Course 2'),
]
for course_run in excluded_runs:
SeatFactory(course_run=course_run)
# The course associated with this run is included in the catalog query,
# but the run is not marketable (no seats), so we don't expect to see the
# associated course in the response.
future = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30)
CourseRunFactory(
enrollment_end=future,
end=future,
course__title='ABC Test Course 3'
)
with self.assertNumQueries(27): course_run = CourseRunFactory(course__title='ABC Test Course')
response = self.client.get(url) for function in state:
function(course_run)
course_run.save()
if state in self.available_states:
course = course_run.course
# This run has no seats, but we still expect its parent course
# to be included.
CourseRunFactory(course=course)
with self.assertNumQueries(26):
response = self.client.get(url)
assert response.status_code == 200
assert response.data['results'] == self.serialize_catalog_course([course], many=True)
# Any course appearing in the response must have at least one serialized run.
assert len(response.data['results'][0]['course_runs']) > 0
else:
with self.assertNumQueries(3):
response = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response.data['results'] == self.serialize_catalog_course(included_courses, many=True) assert response.data['results'] == []
def test_contains_for_course_key(self): def test_contains_for_course_key(self):
""" """
......
...@@ -10,7 +10,7 @@ from rest_framework.response import Response ...@@ -10,7 +10,7 @@ from rest_framework.response import Response
from course_discovery.apps.api import filters, serializers from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination from course_discovery.apps.api.pagination import ProxiedPagination
from course_discovery.apps.api.renderers import CourseRunCSVRenderer from course_discovery.apps.api.renderers import CourseRunCSVRenderer
from course_discovery.apps.api.v1.views import User, prefetch_related_objects_for_courses from course_discovery.apps.api.v1.views import User
from course_discovery.apps.catalogs.models import Catalog from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.course_metadata.models import CourseRun from course_discovery.apps.course_metadata.models import CourseRun
...@@ -86,14 +86,14 @@ class CatalogViewSet(viewsets.ModelViewSet): ...@@ -86,14 +86,14 @@ class CatalogViewSet(viewsets.ModelViewSet):
Only courses with at least one active and marketable course run are returned. Only courses with at least one active and marketable course run are returned.
--- ---
serializer: serializers.CourseSerializerExcludingClosedRuns serializer: serializers.CatalogCourseSerializer
""" """
catalog = self.get_object() catalog = self.get_object()
queryset = catalog.courses().active().marketable() queryset = catalog.courses().available()
queryset = prefetch_related_objects_for_courses(queryset) queryset = serializers.CatalogCourseSerializer.prefetch_queryset(queryset=queryset)
page = self.paginate_queryset(queryset) page = self.paginate_queryset(queryset)
serializer = serializers.CourseSerializerExcludingClosedRuns(page, many=True, context={'request': request}) serializer = serializers.CatalogCourseSerializer(page, many=True, context={'request': request})
return self.get_paginated_response(serializer.data) return self.get_paginated_response(serializer.data)
@detail_route() @detail_route()
......
...@@ -8,16 +8,20 @@ from course_discovery.apps.course_metadata.choices import CourseRunStatus, Progr ...@@ -8,16 +8,20 @@ from course_discovery.apps.course_metadata.choices import CourseRunStatus, Progr
class CourseQuerySet(models.QuerySet): class CourseQuerySet(models.QuerySet):
def active(self): def available(self):
""" """
Filter Courses to those with CourseRuns that have not yet ended and are A Course is considered to be "available" if it contains at least one CourseRun
either open for enrollment or will be open for enrollment in the future. that can be enrolled in immediately, is ongoing or yet to start, and appears
on the marketing site.
""" """
now = datetime.datetime.now(pytz.UTC) now = datetime.datetime.now(pytz.UTC)
return self.filter(
# A CourseRun is "enrollable" if its enrollment start date has passed,
# is now, or is None, and its enrollment end date is in the future or is None.
enrollable = (
( (
Q(course_runs__end__gt=now) | Q(course_runs__enrollment_start__lte=now) |
Q(course_runs__end__isnull=True) Q(course_runs__enrollment_start__isnull=True)
) & ) &
( (
Q(course_runs__enrollment_end__gt=now) | Q(course_runs__enrollment_end__gt=now) |
...@@ -25,24 +29,28 @@ class CourseQuerySet(models.QuerySet): ...@@ -25,24 +29,28 @@ class CourseQuerySet(models.QuerySet):
) )
) )
def marketable(self): # A CourseRun is "not ended" if its end date is in the future or is None.
""" not_ended = (
Filter Courses to those with CourseRuns that can be marketed. A CourseRun Q(course_runs__end__gt=now) | Q(course_runs__end__isnull=True)
is deemed marketable if it has a defined slug, has seats, and has been published. )
"""
# A CourseRun is "marketable" if it has a non-empty slug, has seats, and
# is has a "published" status.
marketable = (
(
Q(course_runs__slug__isnull=False) & ~Q(course_runs__slug='')
) &
Q(course_runs__seats__isnull=False) &
Q(course_runs__status=CourseRunStatus.Published)
)
# exclude() is intentionally avoided here. We want Courses to be included # exclude() is intentionally avoided here. We want Courses to be included
# in the resulting queryset if only one of their runs matches our "marketable" # in the resulting queryset if at least one of their runs matches our availability
# criteria. For example, consider a Course with two CourseRuns; one of the # criteria. For example, consider a Course with two CourseRuns; one of the
# runs is published while the other is not. If you used exclude(), the Course # runs is published while the other is not. If you used exclude(), the Course
# would be dropped from the queryset even though it has one run which matches # would be dropped from the queryset even though it has one run which matches
# our criteria. # our availability criteria.
return self.filter( return self.filter(enrollable & not_ended & marketable)
Q(course_runs__slug__isnull=False) & ~Q(course_runs__slug='')
).filter(
course_runs__seats__isnull=False
).filter(
course_runs__status=CourseRunStatus.Published
).distinct()
class CourseRunQuerySet(models.QuerySet): class CourseRunQuerySet(models.QuerySet):
......
# pylint: disable=no-member
import datetime import datetime
import ddt import ddt
import pytest
import pytz import pytz
from django.test import TestCase from django.test import TestCase
...@@ -9,69 +11,33 @@ from course_discovery.apps.course_metadata.models import Course, CourseRun, Prog ...@@ -9,69 +11,33 @@ from course_discovery.apps.course_metadata.models import Course, CourseRun, Prog
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory, SeatFactory from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory, SeatFactory
@pytest.mark.usefixtures('course_run_states')
class CourseQuerySetTests(TestCase): class CourseQuerySetTests(TestCase):
def test_active(self): def test_available(self):
""" """
Verify the method filters Courses to those with active CourseRuns. Verify the method filters Courses to those which contain at least one
CourseRun that can be enrolled in immediately, is ongoing or yet to start,
and appears on the marketing site.
""" """
now = datetime.datetime.now(pytz.UTC) for state in self.states():
active_course_end = now + datetime.timedelta(days=60) Course.objects.all().delete()
inactive_course_end = now - datetime.timedelta(days=15)
open_enrollment_end = now + datetime.timedelta(days=30)
closed_enrollment_end = now - datetime.timedelta(days=30)
# Create an active, enrollable course run
active_course = CourseRunFactory(enrollment_end=open_enrollment_end, end=active_course_end).course
# Create an active, unenrollable course run course_run = CourseRunFactory()
CourseRunFactory(enrollment_end=closed_enrollment_end, end=active_course_end, course__title='ABC Test Course 2') for function in state:
function(course_run)
# Create an inactive, unenrollable course run course_run.save()
CourseRunFactory(enrollment_end=closed_enrollment_end, end=inactive_course_end)
# Create an active course run with an unspecified enrollment end
course_without_enrollment_end = CourseRunFactory(enrollment_end=None, end=active_course_end).course
# Create an inactive course run with an unspecified enrollment end
CourseRunFactory(enrollment_end=None, end=inactive_course_end)
# Create an enrollable course run with an unspecified end date if state in self.available_states:
course_without_end = CourseRunFactory(enrollment_end=open_enrollment_end, end=None).course course = course_run.course
assert set(Course.objects.active()) == { # This run has no seats, but we still expect its parent course
active_course, course_without_enrollment_end, course_without_end # to be included.
} CourseRunFactory(course=course)
def test_marketable(self): assert set(Course.objects.available()) == {course}
""" else:
Verify the method filters Courses to those with marketable CourseRuns. assert set(Course.objects.available()) == set()
"""
# Courses whose runs have null or empty slugs are excluded, even if
# those runs are published and have seats.
for invalid_slug in (None, ''):
excluded_course_run = CourseRunFactory(slug=invalid_slug)
SeatFactory(course_run=excluded_course_run)
# Courses whose runs have no seats are excluded, even if those runs
# are published and have valid slugs.
CourseRunFactory()
# Courses whose runs are unpublished are excluded, even if those runs
# have seats and valid slugs.
excluded_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished)
SeatFactory(course_run=excluded_course_run)
# Courses with at least one run that is published and has seats and a valid
# slug are included.
included_course_run = CourseRunFactory()
SeatFactory(course_run=included_course_run)
included_course = included_course_run.course
# This run has no seats and will be excluded, but we still expect its parent
# course to be included.
CourseRunFactory(course=included_course)
assert set(Course.objects.marketable()) == {included_course}
@ddt.ddt @ddt.ddt
......
import datetime
from functools import partial
from itertools import product
import pytest
import pytz
from course_discovery.apps.course_metadata.choices import CourseRunStatus
from course_discovery.apps.course_metadata.tests.factories import SeatFactory
@pytest.fixture(scope='class')
def course_run_states(request):
"""
pytest fixture for providing test classes with attributes necessary to create
and test CourseRuns in all states affecting availability.
"""
now = datetime.datetime.now(pytz.UTC)
past = now - datetime.timedelta(days=30)
future = now + datetime.timedelta(days=30)
def enrollment_start_null(course_run):
course_run.enrollment_start = None
def enrollment_start_past(course_run):
course_run.enrollment_start = past
def enrollment_start_future(course_run):
course_run.enrollment_start = future
def enrollment_end_null(course_run):
course_run.enrollment_end = None
def enrollment_end_past(course_run):
course_run.enrollment_end = past
def enrollment_end_future(course_run):
course_run.enrollment_end = future
def end_null(course_run):
course_run.end = None
def end_past(course_run):
course_run.end = past
def end_future(course_run):
course_run.end = future
def slug_null(course_run):
course_run.slug = None
def slug_blank(course_run):
course_run.slug = ''
def slug_valid(course_run):
course_run.slug = 'foo'
def seats_null(course_run): # pylint: disable=unused-argument
pass
def seats_exist(course_run):
SeatFactory(course_run=course_run)
def published(course_run):
course_run.status = CourseRunStatus.Published
def unpublished(course_run):
course_run.status = CourseRunStatus.Unpublished
# The Cartesian product of these lists represents the 324 possible course
# run states that affect a parent course's availability.
states = [
[
enrollment_start_null,
enrollment_start_past,
enrollment_start_future
],
[
enrollment_end_null,
enrollment_end_past,
enrollment_end_future
],
[
end_null,
end_past,
end_future
],
[
slug_null,
slug_blank,
slug_valid
],
[
seats_null,
seats_exist
],
[
published,
unpublished
]
]
# The Cartesian product of these lists represents the 8 possible course
# run states that yield an available parent course.
available_states = [
[
enrollment_start_null,
enrollment_start_past
],
[
enrollment_end_null,
enrollment_end_future
],
[
end_null,
end_future
],
[
slug_valid
],
[
seats_exist
],
[
published
]
]
# Set class attributes on the invoking test context.
request.cls.states = partial(product, *states)
request.cls.available_states = list(product(*available_states))
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