Commit f6ac85f0 by Renzo Lucioni

Allow clients requesting courses to filter out unmarketable runs

Unmarketable runs are defined as those course runs that are published, have seats, and can still be enrolled in, either now or in the future.

ECOM-6768
parent b9b9f388
...@@ -478,6 +478,14 @@ class CourseWithProgramsSerializer(CourseSerializer): ...@@ -478,6 +478,14 @@ class CourseWithProgramsSerializer(CourseSerializer):
def get_course_runs(self, course): def get_course_runs(self, course):
course_runs = course.course_runs.exclude(hidden=True) course_runs = course.course_runs.exclude(hidden=True)
if self.context.get('marketable_course_runs_only'):
# A client requesting marketable_course_runs_only should only receive course runs
# that are published, have seats, and can still be enrolled in. All other course runs
# should be excluded. As an unfortunate side-effect of the way we've marketed course
# runs in the past - a course run could be marketed despite enrollment in that run being
# closed - achieving this requires applying both the marketable and active filters.
course_runs = course_runs.marketable().active()
if self.context.get('published_course_runs_only'): if self.context.get('published_course_runs_only'):
course_runs = course_runs.filter(status=CourseRunStatus.Published) course_runs = course_runs.filter(status=CourseRunStatus.Published)
......
# pylint: disable=no-member, test-inherits-tests # pylint: disable=no-member, test-inherits-tests
from datetime import datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
import ddt import ddt
from django.test import TestCase from django.test import TestCase
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
import pytz
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.fields import ImageField, StdImageSerializerField from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
...@@ -34,7 +35,7 @@ from course_discovery.apps.ietf_language_tags.models import LanguageTag ...@@ -34,7 +35,7 @@ from course_discovery.apps.ietf_language_tags.models import LanguageTag
def json_date_format(datetime_obj): def json_date_format(datetime_obj):
return datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ") return datetime.datetime.strftime(datetime_obj, "%Y-%m-%dT%H:%M:%S.%fZ")
def make_request(): def make_request():
...@@ -200,6 +201,52 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests): ...@@ -200,6 +201,52 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests):
self.assertEqual(serializer.data, self.get_expected_data(self.course, self.request)) self.assertEqual(serializer.data, self.get_expected_data(self.course, self.request))
@ddt.data(0, 1) @ddt.data(0, 1)
def test_marketable_course_runs_only(self, marketable_course_runs_only):
"""
Verify that the marketable_course_runs_only option is respected, restricting returned
course runs to those that are published, have seats, and can still be enrolled in.
"""
# Published course run with a seat, no enrollment start or end, and an end date in the future.
enrollable_course_run = CourseRunFactory(
status=CourseRunStatus.Published,
end=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10),
enrollment_start=None,
enrollment_end=None,
)
SeatFactory(course_run=enrollable_course_run)
# Unpublished course run with a seat.
unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished)
SeatFactory(course_run=unpublished_course_run)
# Published course run with no seats.
no_seats_course_run = CourseRunFactory(status=CourseRunStatus.Published)
# Published course run with a seat and an end date in the past.
closed_course_run = CourseRunFactory(
status=CourseRunStatus.Published,
end=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10),
)
SeatFactory(course_run=closed_course_run)
self.course.course_runs.add(
enrollable_course_run,
unpublished_course_run,
no_seats_course_run,
closed_course_run,
)
serializer = self.serializer_class(
self.course,
context={'request': self.request, 'marketable_course_runs_only': marketable_course_runs_only}
)
self.assertEqual(
len(serializer.data['course_runs']),
1 if marketable_course_runs_only else 4
)
@ddt.data(0, 1)
def test_published_course_runs_only(self, published_course_runs_only): def test_published_course_runs_only(self, published_course_runs_only):
""" """
Test that the published_course_runs_only flag hides unpublished course runs Test that the published_course_runs_only flag hides unpublished course runs
...@@ -207,11 +254,12 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests): ...@@ -207,11 +254,12 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests):
unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished) unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished)
published_course_run = CourseRunFactory(status=CourseRunStatus.Published) published_course_run = CourseRunFactory(status=CourseRunStatus.Published)
self.course.course_runs.add(unpublished_course_run, published_course_run) self.course.course_runs.add(unpublished_course_run, published_course_run)
self.request = make_request()
serializer = self.serializer_class( serializer = self.serializer_class(
self.course, self.course,
context={'request': self.request, 'published_course_runs_only': published_course_runs_only} context={'request': self.request, 'published_course_runs_only': published_course_runs_only}
) )
self.assertEqual(len(serializer.data['course_runs']), 2 - published_course_runs_only) self.assertEqual(len(serializer.data['course_runs']), 2 - published_course_runs_only)
...@@ -542,7 +590,7 @@ class MinimalProgramSerializerTests(TestCase): ...@@ -542,7 +590,7 @@ class MinimalProgramSerializerTests(TestCase):
courses = CourseFactory.create_batch(3) courses = CourseFactory.create_batch(3)
for course in courses: for course in courses:
CourseRunFactory.create_batch(2, course=course, staff=[person], start=datetime.now()) CourseRunFactory.create_batch(2, course=course, staff=[person], start=datetime.datetime.now())
return ProgramFactory( return ProgramFactory(
courses=courses, courses=courses,
...@@ -650,21 +698,21 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -650,21 +698,21 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory( CourseRunFactory(
course=course_list[2], course=course_list[2],
enrollment_start=None, enrollment_start=None,
start=datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1),
) )
# Create a second run with matching start, but later enrollment_start. # Create a second run with matching start, but later enrollment_start.
CourseRunFactory( CourseRunFactory(
course=course_list[1], course=course_list[1],
enrollment_start=datetime(2014, 1, 2), enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1),
) )
# Create a third run with later start and enrollment_start. # Create a third run with later start and enrollment_start.
CourseRunFactory( CourseRunFactory(
course=course_list[0], course=course_list[0],
enrollment_start=datetime(2014, 2, 1), enrollment_start=datetime.datetime(2014, 2, 1),
start=datetime(2014, 3, 1), start=datetime.datetime(2014, 3, 1),
) )
program = ProgramFactory(courses=course_list) program = ProgramFactory(courses=course_list)
...@@ -692,28 +740,28 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -692,28 +740,28 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
excluded_run = CourseRunFactory( excluded_run = CourseRunFactory(
course=course_list[0], course=course_list[0],
enrollment_start=None, enrollment_start=None,
start=datetime(2014, 1, 1), start=datetime.datetime(2014, 1, 1),
) )
# Create a run with later start and empty enrollment_start. # Create a run with later start and empty enrollment_start.
CourseRunFactory( CourseRunFactory(
course=course_list[2], course=course_list[2],
enrollment_start=None, enrollment_start=None,
start=datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1),
) )
# Create a run with matching start, but later enrollment_start. # Create a run with matching start, but later enrollment_start.
CourseRunFactory( CourseRunFactory(
course=course_list[1], course=course_list[1],
enrollment_start=datetime(2014, 1, 2), enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1),
) )
# Create a run with later start and enrollment_start. # Create a run with later start and enrollment_start.
CourseRunFactory( CourseRunFactory(
course=course_list[0], course=course_list[0],
enrollment_start=datetime(2014, 2, 1), enrollment_start=datetime.datetime(2014, 2, 1),
start=datetime(2014, 3, 1), start=datetime.datetime(2014, 3, 1),
) )
program = ProgramFactory(courses=course_list, excluded_course_runs=[excluded_run]) program = ProgramFactory(courses=course_list, excluded_course_runs=[excluded_run])
...@@ -739,14 +787,14 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -739,14 +787,14 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory( CourseRunFactory(
course=course_list[2], course=course_list[2],
enrollment_start=None, enrollment_start=None,
start=datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1),
) )
# Create a second run with matching start, but later enrollment_start. # Create a second run with matching start, but later enrollment_start.
CourseRunFactory( CourseRunFactory(
course=course_list[1], course=course_list[1],
enrollment_start=datetime(2014, 1, 2), enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1),
) )
# Create a third run with empty start and enrollment_start. # Create a third run with empty start and enrollment_start.
......
import datetime
import ddt import ddt
from django.db.models.functions import Lower from django.db.models.functions import Lower
import pytz
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
...@@ -7,7 +10,9 @@ from course_discovery.apps.api.v1.tests.test_views.mixins import SerializationMi ...@@ -7,7 +10,9 @@ from course_discovery.apps.api.v1.tests.test_views.mixins import SerializationMi
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.course_metadata.choices import ProgramStatus, CourseRunStatus from course_discovery.apps.course_metadata.choices import ProgramStatus, CourseRunStatus
from course_discovery.apps.course_metadata.models import Course from course_discovery.apps.course_metadata.models import Course
from course_discovery.apps.course_metadata.tests.factories import CourseFactory, CourseRunFactory, ProgramFactory from course_discovery.apps.course_metadata.tests.factories import (
CourseFactory, CourseRunFactory, ProgramFactory, SeatFactory
)
@ddt.ddt @ddt.ddt
...@@ -55,6 +60,55 @@ class CourseViewSetTests(SerializationMixin, APITestCase): ...@@ -55,6 +60,55 @@ class CourseViewSetTests(SerializationMixin, APITestCase):
) )
@ddt.data(1, 0) @ddt.data(1, 0)
def test_marketable_course_runs_only(self, marketable_course_runs_only):
"""
Verify that a client requesting marketable_course_runs_only only receives
course runs that are published, have seats, and can still be enrolled in.
"""
# Published course run with a seat, no enrollment start or end, and an end date in the future.
enrollable_course_run = CourseRunFactory(
status=CourseRunStatus.Published,
end=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10),
enrollment_start=None,
enrollment_end=None,
)
SeatFactory(course_run=enrollable_course_run)
# Unpublished course run with a seat.
unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished)
SeatFactory(course_run=unpublished_course_run)
# Published course run with no seats.
no_seats_course_run = CourseRunFactory(status=CourseRunStatus.Published)
# Published course run with a seat and an end date in the past.
closed_course_run = CourseRunFactory(
status=CourseRunStatus.Published,
end=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=10),
)
SeatFactory(course_run=closed_course_run)
self.course.course_runs.add(
enrollable_course_run,
unpublished_course_run,
no_seats_course_run,
closed_course_run,
)
url = reverse('api:v1:course-detail', kwargs={'key': self.course.key})
url += '?marketable_course_runs_only={}'.format(marketable_course_runs_only)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
self.serialize_course(
self.course,
extra_context={'marketable_course_runs_only': marketable_course_runs_only}
)
)
@ddt.data(1, 0)
def test_get_include_published_course_run(self, published_course_runs_only): def test_get_include_published_course_run(self, published_course_runs_only):
""" """
Verify the endpoint returns hides unpublished programs if Verify the endpoint returns hides unpublished programs if
......
...@@ -60,7 +60,7 @@ class CourseRunViewSet(PartnerMixin, viewsets.ReadOnlyModelViewSet): ...@@ -60,7 +60,7 @@ class CourseRunViewSet(PartnerMixin, viewsets.ReadOnlyModelViewSet):
return context return context
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
""" List all courses runs. """ List all course runs.
--- ---
parameters: parameters:
- name: q - name: q
......
...@@ -21,16 +21,6 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -21,16 +21,6 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = serializers.CourseWithProgramsSerializer serializer_class = serializers.CourseWithProgramsSerializer
def get_queryset(self): def get_queryset(self):
""" List one course
---
parameters:
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
required: false
type: integer
paramType: query
multiple: false
"""
q = self.request.query_params.get('q', None) q = self.request.query_params.get('q', None)
if q: if q:
...@@ -46,6 +36,7 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -46,6 +36,7 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
context.update({ context.update({
'exclude_utm': get_query_param(self.request, 'exclude_utm'), 'exclude_utm': get_query_param(self.request, 'exclude_utm'),
'include_deleted_programs': get_query_param(self.request, 'include_deleted_programs'), 'include_deleted_programs': get_query_param(self.request, 'include_deleted_programs'),
'marketable_course_runs_only': get_query_param(self.request, 'marketable_course_runs_only'),
'published_course_runs_only': get_query_param(self.request, 'published_course_runs_only'), 'published_course_runs_only': get_query_param(self.request, 'published_course_runs_only'),
}) })
...@@ -55,10 +46,16 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -55,10 +46,16 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
""" List all courses. """ List all courses.
--- ---
parameters: parameters:
- name: q - name: exclude_utm
description: Elasticsearch querystring query. This filter takes precedence over other filters. description: Exclude UTM parameters from marketing URLs.
required: false required: false
type: string type: integer
paramType: query
multiple: false
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
required: false
type: integer
paramType: query paramType: query
multiple: false multiple: false
- name: keys - name: keys
...@@ -67,22 +64,23 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -67,22 +64,23 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
type: string type: string
paramType: query paramType: query
multiple: false multiple: false
- name: published_course_runs_only - name: marketable_course_runs_only
description: Filter course runs by published ones only description: Restrict returned course runs to those that are published, have seats,
and can still be enrolled in.
required: false required: false
type: integer type: integer
paramType: query paramType: query
mulitple: false mulitple: false
- name: exclude_utm - name: published_course_runs_only
description: Exclude UTM parameters from marketing URLs. description: Filter course runs by published ones only
required: false required: false
type: integer type: integer
paramType: query paramType: query
multiple: false mulitple: false
- name: include_deleted_programs - name: q
description: Will include deleted programs in the associated programs array description: Elasticsearch querystring query. This filter takes precedence over other filters.
required: false required: false
type: integer type: string
paramType: query paramType: query
multiple: false multiple: false
""" """
......
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