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):
def get_course_runs(self, course):
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'):
course_runs = course_runs.filter(status=CourseRunStatus.Published)
......
# pylint: disable=no-member, test-inherits-tests
from datetime import datetime
import datetime
from urllib.parse import urlencode
import ddt
from django.test import TestCase
from haystack.query import SearchQuerySet
from opaque_keys.edx.keys import CourseKey
import pytz
from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
......@@ -34,7 +35,7 @@ from course_discovery.apps.ietf_language_tags.models import LanguageTag
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():
......@@ -200,6 +201,52 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests):
self.assertEqual(serializer.data, self.get_expected_data(self.course, self.request))
@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):
"""
Test that the published_course_runs_only flag hides unpublished course runs
......@@ -207,11 +254,12 @@ class CourseWithProgramsSerializerTests(CourseSerializerTests):
unpublished_course_run = CourseRunFactory(status=CourseRunStatus.Unpublished)
published_course_run = CourseRunFactory(status=CourseRunStatus.Published)
self.course.course_runs.add(unpublished_course_run, published_course_run)
self.request = make_request()
serializer = self.serializer_class(
self.course,
context={'request': self.request, 'published_course_runs_only': published_course_runs_only}
)
self.assertEqual(len(serializer.data['course_runs']), 2 - published_course_runs_only)
......@@ -542,7 +590,7 @@ class MinimalProgramSerializerTests(TestCase):
courses = CourseFactory.create_batch(3)
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(
courses=courses,
......@@ -650,21 +698,21 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory(
course=course_list[2],
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.
CourseRunFactory(
course=course_list[1],
enrollment_start=datetime(2014, 1, 2),
start=datetime(2014, 2, 1),
enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime.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),
enrollment_start=datetime.datetime(2014, 2, 1),
start=datetime.datetime(2014, 3, 1),
)
program = ProgramFactory(courses=course_list)
......@@ -692,28 +740,28 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
excluded_run = CourseRunFactory(
course=course_list[0],
enrollment_start=None,
start=datetime(2014, 1, 1),
start=datetime.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),
start=datetime.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),
enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime.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),
enrollment_start=datetime.datetime(2014, 2, 1),
start=datetime.datetime(2014, 3, 1),
)
program = ProgramFactory(courses=course_list, excluded_course_runs=[excluded_run])
......@@ -739,14 +787,14 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory(
course=course_list[2],
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.
CourseRunFactory(
course=course_list[1],
enrollment_start=datetime(2014, 1, 2),
start=datetime(2014, 2, 1),
enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime.datetime(2014, 2, 1),
)
# Create a third run with empty start and enrollment_start.
......
import datetime
import ddt
from django.db.models.functions import Lower
import pytz
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase
......@@ -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.course_metadata.choices import ProgramStatus, CourseRunStatus
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
......@@ -55,6 +60,55 @@ class CourseViewSetTests(SerializationMixin, APITestCase):
)
@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):
"""
Verify the endpoint returns hides unpublished programs if
......
......@@ -60,7 +60,7 @@ class CourseRunViewSet(PartnerMixin, viewsets.ReadOnlyModelViewSet):
return context
def list(self, request, *args, **kwargs):
""" List all courses runs.
""" List all course runs.
---
parameters:
- name: q
......
......@@ -21,16 +21,6 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = serializers.CourseWithProgramsSerializer
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)
if q:
......@@ -46,6 +36,7 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
context.update({
'exclude_utm': get_query_param(self.request, 'exclude_utm'),
'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'),
})
......@@ -55,10 +46,16 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
""" List all courses.
---
parameters:
- name: q
description: Elasticsearch querystring query. This filter takes precedence over other filters.
- name: exclude_utm
description: Exclude UTM parameters from marketing URLs.
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
multiple: false
- name: keys
......@@ -67,22 +64,23 @@ class CourseViewSet(viewsets.ReadOnlyModelViewSet):
type: string
paramType: query
multiple: false
- name: published_course_runs_only
description: Filter course runs by published ones only
- name: marketable_course_runs_only
description: Restrict returned course runs to those that are published, have seats,
and can still be enrolled in.
required: false
type: integer
paramType: query
mulitple: false
- name: exclude_utm
description: Exclude UTM parameters from marketing URLs.
- name: published_course_runs_only
description: Filter course runs by published ones only
required: false
type: integer
paramType: query
multiple: false
- name: include_deleted_programs
description: Will include deleted programs in the associated programs array
mulitple: false
- name: q
description: Elasticsearch querystring query. This filter takes precedence over other filters.
required: false
type: integer
type: string
paramType: query
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