Commit a8f3a501 by Anthony Mangano

Add boosting for courses with enrollable paid seats

ECOM-6715
parent a0f3439b
import datetime
import json
import urllib.parse
from mock import patch
import ddt
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
from haystack.query import SearchQuerySet
import pytz
from rest_framework.test import APITestCase
from course_discovery.apps.api.serializers import (
......@@ -364,7 +367,6 @@ class AggregateSearchViewSet(DefaultPartnerMixin, SerializationMixin, LoginMixin
self.assertEqual(response.data['objects']['results'], expected)
@ddt.ddt
class TypeaheadSearchViewTests(DefaultPartnerMixin, TypeaheadSerializationMixin, LoginMixin, ElasticsearchTestMixin,
APITestCase):
path = reverse('api:v1:search-typeahead')
......@@ -459,47 +461,6 @@ class TypeaheadSearchViewTests(DefaultPartnerMixin, TypeaheadSerializationMixin,
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data, ["The 'q' querystring parameter is required for searching."])
@ddt.data('MicroMasters', 'Professional Certificate')
def test_program_type_boosting(self, program_type):
""" Verify MicroMasters and Professional Certificate are boosted over XSeries."""
title = program_type
ProgramFactory(
title=title + "1", status=ProgramStatus.Active,
type=ProgramType.objects.get(name='XSeries'), partner=self.partner
)
ProgramFactory(
title=title + "2",
status=ProgramStatus.Active,
type=ProgramType.objects.get(name=program_type),
partner=self.partner
)
response = self.get_typeahead_response(title)
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertEqual(response_data['programs'][0]['type'], program_type)
self.assertEqual(response_data['programs'][0]['title'], title + "2")
def test_start_date_boosting(self):
""" Verify upcoming courses are boosted over past courses."""
title = "start"
now = datetime.datetime.utcnow()
CourseRunFactory(title=title + "1", start=now - datetime.timedelta(weeks=10), course__partner=self.partner)
CourseRunFactory(title=title + "2", start=now + datetime.timedelta(weeks=1), course__partner=self.partner)
response = self.get_typeahead_response(title)
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertEqual(response_data['course_runs'][0]['title'], title + "2")
def test_self_paced_boosting(self):
""" Verify that self paced courses are boosted over instructor led courses."""
title = "paced"
CourseRunFactory(title=title + "1", pacing_type='instructor_paced', course__partner=self.partner)
CourseRunFactory(title=title + "2", pacing_type='self_paced', course__partner=self.partner)
response = self.get_typeahead_response(title)
self.assertEqual(response.status_code, 200)
response_data = response.json()
self.assertEqual(response_data['course_runs'][0]['title'], title + "2")
def test_typeahead_authoring_organizations_partial_search(self):
""" Test typeahead response with partial organization matching. """
authoring_organizations = OrganizationFactory.create_batch(3)
......@@ -568,3 +529,81 @@ class TypeaheadSearchViewTests(DefaultPartnerMixin, TypeaheadSerializationMixin,
edx_program = programs[0]
self.assertDictEqual(response.data, {'course_runs': [self.serialize_course_run(edx_course_run)],
'programs': [self.serialize_program(edx_program)]})
@ddt.ddt
class SearchBoostingTests(ElasticsearchTestMixin, TestCase):
def build_normalized_course_run(self, **kwargs):
""" Builds a CourseRun with fields set to normalize boosting behavior."""
defaults = {
'pacing_type': 'instructor_paced',
'start': datetime.datetime.now(pytz.timezone('utc')) + datetime.timedelta(weeks=52),
}
defaults.update(kwargs)
return CourseRunFactory(**defaults)
def test_start_date_boosting(self):
""" Verify upcoming courses are boosted over past courses."""
now = datetime.datetime.now(pytz.timezone('utc'))
self.build_normalized_course_run(start=now + datetime.timedelta(weeks=10))
test_record = self.build_normalized_course_run(start=now + datetime.timedelta(weeks=1))
search_results = SearchQuerySet().models(CourseRun).all()
self.assertEqual(2, len(search_results))
self.assertGreater(search_results[0].score, search_results[1].score)
self.assertEqual(int(test_record.start.timestamp()), int(search_results[0].start.timestamp())) # pylint: disable=no-member
def test_self_paced_boosting(self):
""" Verify that self paced courses are boosted over instructor led courses."""
self.build_normalized_course_run(pacing_type='instructor_paced')
test_record = self.build_normalized_course_run(pacing_type='self_paced')
search_results = SearchQuerySet().models(CourseRun).all()
self.assertEqual(2, len(search_results))
self.assertGreater(search_results[0].score, search_results[1].score)
self.assertEqual(test_record.pacing_type, search_results[0].pacing_type)
@ddt.data(
# Case 1: Should not get boost if has_enrollable_paid_seats is False, has_enrollable_paid_seats is None or
# paid_seat_enrollment_end is in the past.
(False, None, False),
(None, None, False),
(True, datetime.datetime.now(pytz.timezone('utc')) - datetime.timedelta(days=15), False),
# Case 2: Should get boost if has_enrollable_paid_seats is True and paid_seat_enrollment_end is None or
# in the future.
(True, None, True),
(True, datetime.datetime.now(pytz.timezone('utc')) + datetime.timedelta(days=15), True)
)
@ddt.unpack
def test_enrollable_paid_seat_boosting(self, has_enrollable_paid_seats, paid_seat_enrollment_end, expects_boost):
""" Verify that CourseRuns for which an unenrolled user may enroll and purchase a paid Seat are boosted."""
# Create a control record (one that should never be boosted).
with patch.object(CourseRun, 'has_enrollable_paid_seats', return_value=False):
with patch.object(CourseRun, 'get_paid_seat_enrollment_end', return_value=None):
self.build_normalized_course_run(title='test1')
# Create the test record (may be boosted).
with patch.object(CourseRun, 'has_enrollable_paid_seats', return_value=has_enrollable_paid_seats):
with patch.object(CourseRun, 'get_paid_seat_enrollment_end', return_value=paid_seat_enrollment_end):
test_record = self.build_normalized_course_run(title='test2')
search_results = SearchQuerySet().models(CourseRun).all()
self.assertEqual(2, len(search_results))
if expects_boost:
self.assertGreater(search_results[0].score, search_results[1].score)
self.assertEqual(test_record.title, search_results[0].title)
else:
self.assertEqual(search_results[0].score, search_results[1].score)
@ddt.data('MicroMasters', 'Professional Certificate')
def test_program_type_boosting(self, program_type):
""" Verify MicroMasters and Professional Certificate are boosted over XSeries."""
ProgramFactory(type=ProgramType.objects.get(name='XSeries'))
test_record = ProgramFactory(type=ProgramType.objects.get(name=program_type))
search_results = SearchQuerySet().models(Program).all()
self.assertEqual(2, len(search_results))
self.assertGreater(search_results[0].score, search_results[1].score)
self.assertEqual(str(test_record.type), str(search_results[0].type))
......@@ -381,6 +381,47 @@ class CourseRun(TimeStampedModel):
objects = CourseRunQuerySet.as_manager()
def _enrollable_paid_seats(self):
"""
Return a QuerySet that may be used to fetch the enrollable paid Seats (Seats with price > 0 and no
prerequisites) associated with this CourseRun.
"""
return self.seats.exclude(type__in=Seat.SEATS_WITH_PREREQUISITES).filter(price__gt=0.0)
def has_enrollable_paid_seats(self):
"""
Return a boolean indicating whether or not enrollable paid Seats (Seats with price > 0 and no prerequisites)
are available for this CourseRun.
"""
return len(self._enrollable_paid_seats()[:1]) > 0
def get_paid_seat_enrollment_end(self):
"""
Return the final date for which an unenrolled user may enroll and purchase a paid Seat for this CourseRun, or
None if the date is unknown or enrollable paid Seats are not available.
"""
seats = list(self._enrollable_paid_seats().order_by('-upgrade_deadline'))
if len(seats) == 0:
# Enrollable paid seats are not available for this CourseRun.
return None
# An unenrolled user may not enroll and purchase paid seats after the course has ended.
deadline = self.end
# An unenrolled user may not enroll and purchase paid seats after enrollment has ended.
if self.enrollment_end and (deadline is None or self.enrollment_end < deadline):
deadline = self.enrollment_end
# Note that even though we're sorting in descending order by upgrade_deadline, we will need to look at
# both the first and last record in the result set to determine which Seat has the latest upgrade_deadline.
# We consider Null values to be > than non-Null values, and Null values may sort to the top or bottom of
# the result set, depending on the DB backend.
latest_seat = seats[-1] if seats[-1].upgrade_deadline is None else seats[0]
if latest_seat.upgrade_deadline and (deadline is None or latest_seat.upgrade_deadline < deadline):
deadline = latest_seat.upgrade_deadline
return deadline
@property
def program_types(self):
"""
......@@ -525,6 +566,10 @@ class Seat(TimeStampedModel):
PROFESSIONAL = 'professional'
CREDIT = 'credit'
# Seat types that may not be purchased without first purchasing another Seat type.
# EX: 'credit' seats may not be purchased without first purchasing a 'verified' Seat.
SEATS_WITH_PREREQUISITES = [CREDIT]
SEAT_TYPE_CHOICES = (
(HONOR, _('Honor')),
(AUDIT, _('Audit')),
......
......@@ -150,6 +150,14 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
authoring_organization_uuids = indexes.MultiValueField()
staff_uuids = indexes.MultiValueField()
subject_uuids = indexes.MultiValueField()
has_enrollable_paid_seats = indexes.BooleanField(null=False)
paid_seat_enrollment_end = indexes.DateTimeField(null=True)
def prepare_has_enrollable_paid_seats(self, obj):
return obj.has_enrollable_paid_seats()
def prepare_paid_seat_enrollment_end(self, obj):
return obj.get_paid_seat_enrollment_end()
def prepare_partner(self, obj):
return obj.course.partner.short_code
......
......@@ -19,7 +19,7 @@ from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import (
AbstractMediaModel, AbstractNamedModel, AbstractValueModel,
CorporateEndorsement, Course, CourseRun, Endorsement, FAQ, SeatType, ProgramType,
CorporateEndorsement, Course, CourseRun, Endorsement, FAQ, Seat, SeatType, ProgramType,
)
from course_discovery.apps.course_metadata.publishers import MarketingSitePublisher
from course_discovery.apps.course_metadata.tests import factories, toggle_switch
......@@ -186,6 +186,83 @@ class CourseRunTests(TestCase):
factories.ProgramFactory(courses=[self.course_run.course], status=ProgramStatus.Deleted)
self.assertEqual(self.course_run.program_types, [active_program.type.name])
@ddt.data(
# Case 1: Return False when there are no paid Seats.
([('audit', 0)], False),
([('audit', 0), ('verified', 0)], False),
# Case 2: Return False when there are no paid Seats without prerequisites.
([(seat_type, 1) for seat_type in Seat.SEATS_WITH_PREREQUISITES], False),
# Case 3: Return True when there is at least one paid Seat without prerequisites.
([('audit', 0), ('verified', 1)], True),
([('audit', 0), ('verified', 1), ('professional', 1)], True),
([('audit', 0), ('verified', 1)] + [(seat_type, 1) for seat_type in Seat.SEATS_WITH_PREREQUISITES], True),
)
@ddt.unpack
def test_has_enrollable_paid_seats(self, seat_config, expected_result):
"""
Verify that has_enrollable_paid_seats is True when CourseRun has Seats with price > 0 and no prerequisites.
"""
course_run = factories.CourseRunFactory.create()
for seat_type, price in seat_config:
factories.SeatFactory.create(course_run=course_run, type=seat_type, price=price)
self.assertEqual(course_run.has_enrollable_paid_seats(), expected_result)
@ddt.data(
# Case 1: Return None when there are no enrollable paid Seats.
([('audit', 0, None)], '2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', None),
([(seat_type, 1, None) for seat_type in Seat.SEATS_WITH_PREREQUISITES],
'2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', None),
# Case 2: Return the latest upgrade_deadline of the enrollable paid Seats when it's earlier than
# enrollment_end and course end.
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z')],
'2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', '2016-07-30 00:00:00Z'),
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, '2016-08-15 00:00:00Z')],
'2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', '2016-08-15 00:00:00Z'),
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z')] +
[(seat_type, 1, '2016-08-15 00:00:00Z') for seat_type in Seat.SEATS_WITH_PREREQUISITES],
'2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', '2016-07-30 00:00:00Z'),
# Case 3: Return enrollment_end when it's earlier than course end and the latest upgrade_deadline of the
# enrollable paid Seats, or when one of those Seats does not have an upgrade_deadline.
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, '2016-09-15 00:00:00Z')],
'2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', '2016-08-31 00:00:00Z'),
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, None)],
'2016-12-31 00:00:00Z', '2016-08-31 00:00:00Z', '2016-08-31 00:00:00Z'),
# Case 4: Return course end when it's earlier than enrollment_end or enrollment_end is None, and it's earlier
# than the latest upgrade_deadline of the enrollable paid Seats or when one of those Seats does not have an
# upgrade_deadline.
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, '2017-09-15 00:00:00Z')],
'2016-12-31 00:00:00Z', '2017-08-31 00:00:00Z', '2016-12-31 00:00:00Z'),
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, None)],
'2016-12-31 00:00:00Z', '2017-12-31 00:00:00Z', '2016-12-31 00:00:00Z'),
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, None)],
'2016-12-31 00:00:00Z', None, '2016-12-31 00:00:00Z'),
# Case 5: Return None when course end and enrollment_end are None and there's an enrollable paid Seat without
# an upgrade_deadline, even when there's another enrollable paid Seat with an upgrade_deadline.
([('audit', 0, None), ('verified', 1, '2016-07-30 00:00:00Z'), ('professional', 1, None)],
None, None, None)
)
@ddt.unpack
def test_get_paid_seat_enrollment_end(self, seat_config, course_end, course_enrollment_end, expected_result):
"""
Verify that paid_seat_enrollment_end returns the latest possible date for which an unerolled user may
enroll and purchase an upgrade for the CourseRun or None if date unknown or paid Seats are not available.
"""
end = parse(course_end) if course_end else None
enrollment_end = parse(course_enrollment_end) if course_enrollment_end else None
course_run = factories.CourseRunFactory.create(end=end, enrollment_end=enrollment_end)
for seat_type, price, deadline in seat_config:
deadline = parse(deadline) if deadline else None
factories.SeatFactory.create(course_run=course_run, type=seat_type, price=price, upgrade_deadline=deadline)
expected_result = parse(expected_result) if expected_result else None
self.assertEqual(course_run.get_paid_seat_enrollment_end(), expected_result)
class OrganizationTests(TestCase):
""" Tests for the `Organization` model. """
......
# -*- coding: utf-8 -*-
# Generated by Django 1.9.11 on 2017-01-26 15:10
from __future__ import unicode_literals
from django.db import migrations
def add_enrollable_paid_seat_boosting(apps, schema_editor):
"""Add enrollable paid Seat boosting function to ElasticsearchBoostConfig instance."""
# Get the model from the versioned app registry to ensure the correct version is used, as described in
# https://docs.djangoproject.com/en/1.8/ref/migration-operations/#runpython
ElasticsearchBoostConfig = apps.get_model('edx_haystack_extensions', 'ElasticsearchBoostConfig')
ElasticsearchBoostConfig.objects.update_or_create(
# The `solo` library uses 1 for the PrimaryKey to create/lookup the singleton record
# See https://github.com/lazybird/django-solo/blob/1.1.2/solo/models.py
pk=1,
defaults={
'function_score': {
'boost_mode': 'sum',
'boost': 1.0,
'score_mode': 'sum',
'functions': [
{'filter': {'term': {'pacing_type_exact': 'self_paced'}}, 'weight': 1.0},
{'filter': {'term': {'type_exact': 'Professional Certificate'}}, 'weight': 1.0},
{'filter': {'term': {'type_exact': 'MicroMasters'}}, 'weight': 1.0},
{'linear': {'start': {'origin': 'now', 'decay': 0.95, 'scale': '1d'}}, 'weight': 5.0},
# Boost function for CourseRuns with enrollable paid Seats.
# We want to boost if:
# - The course run has at least one enrollable paid Seat (has_enrollable_paid_seats is True)
# AND one of the following two conditions are true
# - The paid_seat_enrollment_end is unspecified.
# - The paid_seat_enrollment_end is in the future.
# We apply a weight of 1.0 to match the boost given for self paced courses.
{
'filter': {
'bool': {
'must': [
{'exists': {'field': 'has_enrollable_paid_seats'}},
{'term': {'has_enrollable_paid_seats': True}}
],
'should': [
{'bool': {'must_not': { 'exists': {'field': 'paid_seat_enrollment_end'}}}},
{'range': {'paid_seat_enrollment_end': {'gte': 'now'}}}
]
}
},
'weight': 1.0
}
]
}
}
)
class Migration(migrations.Migration):
dependencies = [
('edx_haystack_extensions', '0003_auto_20170124_1834'),
]
operations = [
migrations.RunPython(add_enrollable_paid_seat_boosting, migrations.RunPython.noop)
]
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