Commit 2d669183 by Matthew Piatetsky

Boost current upgradeable runs over future runs whose start date is closer

LEARNER-2935
parent 519f9189
...@@ -6,33 +6,28 @@ from urllib.parse import urlencode ...@@ -6,33 +6,28 @@ from urllib.parse import urlencode
import ddt import ddt
import mock import mock
import pytest import pytest
import pytz
import responses import responses
from django.test import TestCase from django.test import TestCase
from django.utils.text import slugify from django.utils.text import slugify
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from rest_framework.test import APIRequestFactory from rest_framework.test import APIRequestFactory
from waffle.models import Switch from waffle.models import Switch
from waffle.testutils import override_switch from waffle.testutils import override_switch
from course_discovery.apps.api.fields import ImageField, StdImageSerializerField from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
from course_discovery.apps.api.serializers import (AffiliateWindowSerializer, CatalogSerializer, from course_discovery.apps.api.serializers import (
ContainedCourseRunsSerializer, ContainedCoursesSerializer, AffiliateWindowSerializer, CatalogSerializer, ContainedCourseRunsSerializer, ContainedCoursesSerializer,
CorporateEndorsementSerializer, CourseEntitlementSerializer, CorporateEndorsementSerializer, CourseEntitlementSerializer, CourseRunSearchSerializer, CourseRunSerializer,
CourseRunSearchSerializer, CourseRunSerializer, CourseRunWithProgramsSerializer, CourseSearchSerializer, CourseSerializer, CourseWithProgramsSerializer,
CourseRunWithProgramsSerializer, CourseSearchSerializer, EndorsementSerializer, FAQSerializer, FlattenedCourseRunWithCourseSerializer, ImageSerializer,
CourseSerializer, CourseWithProgramsSerializer, MinimalCourseRunSerializer, MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalProgramCourseSerializer,
EndorsementSerializer, FAQSerializer, MinimalProgramSerializer, NestedProgramSerializer, OrganizationSerializer, PersonSerializer, PositionSerializer,
FlattenedCourseRunWithCourseSerializer, ImageSerializer, PrerequisiteSerializer, ProgramSearchSerializer, ProgramSerializer, ProgramTypeSerializer, SeatSerializer,
MinimalCourseRunSerializer, MinimalCourseSerializer, SubjectSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer, VideoSerializer,
MinimalOrganizationSerializer, MinimalProgramCourseSerializer, get_utm_source_for_user
MinimalProgramSerializer, NestedProgramSerializer, )
OrganizationSerializer, PersonSerializer, PositionSerializer,
PrerequisiteSerializer, ProgramSearchSerializer, ProgramSerializer,
ProgramTypeSerializer, SeatSerializer, SubjectSerializer,
TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer,
VideoSerializer, get_utm_source_for_user)
from course_discovery.apps.api.tests.mixins import SiteMixin from course_discovery.apps.api.tests.mixins import SiteMixin
from course_discovery.apps.catalogs.tests.factories import CatalogFactory from course_discovery.apps.catalogs.tests.factories import CatalogFactory
from course_discovery.apps.core.models import Partner, User from course_discovery.apps.core.models import Partner, User
...@@ -41,13 +36,11 @@ from course_discovery.apps.core.tests.helpers import make_image_file ...@@ -41,13 +36,11 @@ from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin, LMSAPIClientMixin from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin, LMSAPIClientMixin
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.models import Course, CourseRun, Program from course_discovery.apps.course_metadata.models import Course, CourseRun, Program
from course_discovery.apps.course_metadata.tests.factories import (CorporateEndorsementFactory, CourseFactory, from course_discovery.apps.course_metadata.tests.factories import (
CourseRunFactory, EndorsementFactory, CorporateEndorsementFactory, CourseFactory, CourseRunFactory, EndorsementFactory, ExpectedLearningItemFactory,
ExpectedLearningItemFactory, ImageFactory, ImageFactory, JobOutlookItemFactory, OrganizationFactory, PersonFactory, PositionFactory, PrerequisiteFactory,
JobOutlookItemFactory, OrganizationFactory, ProgramFactory, ProgramTypeFactory, SeatFactory, SeatTypeFactory, SubjectFactory, VideoFactory
PersonFactory, PositionFactory, PrerequisiteFactory, )
ProgramFactory, ProgramTypeFactory, SeatFactory,
SeatTypeFactory, SubjectFactory, VideoFactory)
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
...@@ -615,7 +608,7 @@ class MinimalProgramSerializerTests(TestCase): ...@@ -615,7 +608,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.datetime.now(pytz.UTC)) CourseRunFactory.create_batch(2, course=course, staff=[person], start=datetime.datetime.now(UTC))
return ProgramFactory( return ProgramFactory(
courses=courses, courses=courses,
...@@ -730,21 +723,21 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -730,21 +723,21 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory( CourseRunFactory(
course=course_list[2], course=course_list[2],
enrollment_start=None, enrollment_start=None,
start=datetime.datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
) )
# 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.datetime(2014, 1, 2), enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime.datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
) )
# 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.datetime(2014, 2, 1), enrollment_start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
start=datetime.datetime(2014, 3, 1), start=datetime.datetime(2014, 3, 1, tzinfo=UTC),
) )
program = ProgramFactory(courses=course_list) program = ProgramFactory(courses=course_list)
...@@ -772,28 +765,28 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -772,28 +765,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.datetime(2014, 1, 1), start=datetime.datetime(2014, 1, 1, tzinfo=UTC),
) )
# 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.datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
) )
# 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.datetime(2014, 1, 2), enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime.datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
) )
# 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.datetime(2014, 2, 1), enrollment_start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
start=datetime.datetime(2014, 3, 1), start=datetime.datetime(2014, 3, 1, tzinfo=UTC),
) )
program = ProgramFactory(courses=course_list, excluded_course_runs=[excluded_run]) program = ProgramFactory(courses=course_list, excluded_course_runs=[excluded_run])
...@@ -819,14 +812,14 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -819,14 +812,14 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory( CourseRunFactory(
course=course_list[2], course=course_list[2],
enrollment_start=None, enrollment_start=None,
start=datetime.datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
) )
# 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.datetime(2014, 1, 2), enrollment_start=datetime.datetime(2014, 1, 2),
start=datetime.datetime(2014, 2, 1), start=datetime.datetime(2014, 2, 1, tzinfo=UTC),
) )
# Create a third run with empty start and enrollment_start. # Create a third run with empty start and enrollment_start.
...@@ -867,7 +860,7 @@ class ProgramSerializerTests(MinimalProgramSerializerTests): ...@@ -867,7 +860,7 @@ class ProgramSerializerTests(MinimalProgramSerializerTests):
CourseRunFactory(status=CourseRunStatus.Unpublished, course=course) CourseRunFactory(status=CourseRunStatus.Unpublished, course=course)
marketable_enrollable_run = CourseRunFactory( marketable_enrollable_run = CourseRunFactory(
status=CourseRunStatus.Published, status=CourseRunStatus.Published,
end=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=10), end=datetime.datetime.now(UTC) + datetime.timedelta(days=10),
enrollment_start=None, enrollment_start=None,
enrollment_end=None, enrollment_end=None,
course=course course=course
......
...@@ -484,6 +484,23 @@ class CourseRun(TimeStampedModel): ...@@ -484,6 +484,23 @@ class CourseRun(TimeStampedModel):
""" """
return len(self._enrollable_paid_seats()[:1]) > 0 return len(self._enrollable_paid_seats()[:1]) > 0
def is_current_and_still_upgradeable(self):
"""
Return true if
1. Today is after the run start (or start is none) and two weeks from the run end (or end is none)
2. The run has a seat that is still enrollable and upgradeable
and false otherwise
"""
now = datetime.datetime.now(pytz.UTC)
two_weeks = datetime.timedelta(days=14)
after_start = (not self.start) or (self.start and self.start < now)
ends_in_more_than_two_weeks = (not self.end) or (self.end.date() and now.date() <= self.end.date() - two_weeks)
if after_start and ends_in_more_than_two_weeks:
paid_seat_enrollment_end = self.get_paid_seat_enrollment_end()
if paid_seat_enrollment_end and now < paid_seat_enrollment_end:
return True
return False
def get_paid_seat_enrollment_end(self): 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 Return the final date for which an unenrolled user may enroll and purchase a paid Seat for this CourseRun, or
......
...@@ -171,6 +171,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -171,6 +171,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
paid_seat_enrollment_end = indexes.DateTimeField(null=True) paid_seat_enrollment_end = indexes.DateTimeField(null=True)
license = indexes.MultiValueField(model_attr='license', faceted=True) license = indexes.MultiValueField(model_attr='license', faceted=True)
has_enrollable_seats = indexes.BooleanField(model_attr='has_enrollable_seats', null=False) has_enrollable_seats = indexes.BooleanField(model_attr='has_enrollable_seats', null=False)
is_current_and_still_upgradeable = indexes.BooleanField(null=False)
def prepare_aggregation_key(self, obj): def prepare_aggregation_key(self, obj):
# Aggregate CourseRuns by Course key since that is how we plan to dedup CourseRuns on the marketing site. # Aggregate CourseRuns by Course key since that is how we plan to dedup CourseRuns on the marketing site.
...@@ -179,6 +180,9 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -179,6 +180,9 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
def prepare_has_enrollable_paid_seats(self, obj): def prepare_has_enrollable_paid_seats(self, obj):
return obj.has_enrollable_paid_seats() return obj.has_enrollable_paid_seats()
def prepare_is_current_and_still_upgradeable(self, obj):
return obj.is_current_and_still_upgradeable()
def prepare_paid_seat_enrollment_end(self, obj): def prepare_paid_seat_enrollment_end(self, obj):
return obj.get_paid_seat_enrollment_end() return obj.get_paid_seat_enrollment_end()
......
...@@ -7,6 +7,7 @@ import mock ...@@ -7,6 +7,7 @@ import mock
import pytest import pytest
import pytz import pytz
from dateutil.parser import parse from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
...@@ -284,6 +285,31 @@ class CourseRunTests(TestCase): ...@@ -284,6 +285,31 @@ class CourseRunTests(TestCase):
expected_result = parse(expected_result) if expected_result else None expected_result = parse(expected_result) if expected_result else None
self.assertEqual(course_run.get_paid_seat_enrollment_end(), expected_result) self.assertEqual(course_run.get_paid_seat_enrollment_end(), expected_result)
now = datetime.datetime.now(pytz.timezone('utc'))
one_month = relativedelta(months=1)
two_weeks = relativedelta(days=14)
@ddt.data(
(None, None, None, False),
(now - one_month, None, None, False),
(now - one_month, now + one_month, None, True),
(now - one_month, now - one_month, now - two_weeks, False),
(now - one_month, now + one_month, now - two_weeks, False),
(now - one_month, now + one_month, now + two_weeks, True),
(now + one_month, now + one_month, now + two_weeks, False),
)
@ddt.unpack
def test_is_current_and_still_upgradeable(self, start, end, deadline, is_current):
"""
Verify that is_current_and_still_upgradeable returns true if
1. Today is after the run start (or start is none) and two weeks from the run end (or end is none)
2. The run has a seat that is still enrollable and upgradeable
and false otherwise
"""
course_run = factories.CourseRunFactory.create(start=start, end=end, enrollment_end=end)
factories.SeatFactory.create(course_run=course_run, upgrade_deadline=deadline, type='verified', price=1)
assert course_run.is_current_and_still_upgradeable() == is_current
def test_publication_disabled(self): def test_publication_disabled(self):
""" """
Verify that the publisher is not initialized when publication is disabled. Verify that the publisher is not initialized when publication is disabled.
......
...@@ -79,6 +79,20 @@ def get_elasticsearch_boost_config(): ...@@ -79,6 +79,20 @@ def get_elasticsearch_boost_config():
'weight': 5.0 'weight': 5.0
}, },
# Reward course runs that are currently running and still upgradeable
{
'filter': {
'bool': {
'must': {
'term': {
'is_current_and_still_upgradeable': True
}
}
}
},
'weight': 10.0
},
# Reward course runs with enrollable, paid seats. # Reward course runs with enrollable, paid seats.
{ {
'filter': { 'filter': {
......
...@@ -2,6 +2,7 @@ import datetime ...@@ -2,6 +2,7 @@ import datetime
import pytest import pytest
import pytz import pytz
from dateutil.relativedelta import relativedelta
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from mock import patch from mock import patch
...@@ -167,3 +168,72 @@ class TestSearchBoosting: ...@@ -167,3 +168,72 @@ class TestSearchBoosting:
assert test_record.title == search_results[0].title assert test_record.title == search_results[0].title
else: else:
assert search_results[0].score == search_results[1].score assert search_results[0].score == search_results[1].score
now = datetime.datetime.now(pytz.timezone('utc'))
one_day = relativedelta(days=1)
one_month = relativedelta(months=1)
two_months = relativedelta(months=2)
three_months = relativedelta(months=3)
six_months = relativedelta(months=6)
one_week = relativedelta(days=7)
two_weeks = relativedelta(days=14)
one_year = relativedelta(year=1)
thirteen_months = relativedelta(months=13)
@pytest.mark.parametrize(
'runadates, runbdates, pacing_type, boosted',
[
# Current Self Paced Course (A) vs Future Self Paced Course (B)
((now - one_month, now + one_month), (now + one_month, now + two_months), 'self_paced', 'a'),
# Further Start Current Self Paced Course (A)
# vs Closer Start Current Self Paced Course (B)
((now - two_months, now + one_month), (now - one_month, now + two_months), 'self_paced', 'b'),
# Closer Start Future Self Paced Course (A)
# vs Further Start Future Self Paced Course (B)
((now + one_month, now + one_year), (now + two_months, now + thirteen_months), 'self_paced', 'a'),
# Current Self Paced Course (A) that ends in two weeks
# vs Future Self Paced Course that starts in two weeks (B)
((now - six_months, now + two_weeks), (now + two_weeks, now + six_months), 'self_paced', 'a'),
# Current Instructor Paced Course that ends in one week (A)
# vs Future Instructor Paced Course that starts in one day (B)
((now - six_months, now + one_week), (now + one_day, now + six_months), 'instructor_paced', 'b'),
# Current Instructor Paced Course that ends in two weeks (A)
# vs Future Instructor Paced Course that starts in one week (B)
((now - six_months, now + two_weeks), (now + one_week, now + six_months), 'instructor_paced', 'a'),
# Future Instructor Paced Course that starts tomorrow (A)
# vs Current Instructor Paced Course that is 2/3rds of the way done (B)
((now + one_day, now + six_months), (now - six_months, now + three_months), 'instructor_paced', 'b'),
]
)
def test_current_run_boosting(self, runadates, runbdates, pacing_type, boosted):
"""Verify that "current" CourseRuns are boosted.
See the is_current_and_still_upgradeable CourseRun property to understand what this means."""
(starta, enda) = runadates
(startb, endb) = runbdates
now = datetime.datetime.now(pytz.timezone('utc'))
upgrade_deadline_tomorrow = now + relativedelta(days=1)
with patch.object(CourseRun, 'get_paid_seat_enrollment_end', return_value=upgrade_deadline_tomorrow):
runa = self.build_normalized_course_run(
title='test1',
start=starta,
end=enda,
pacing_type=pacing_type
)
runb = self.build_normalized_course_run(
title='test2',
start=startb,
end=endb,
pacing_type=pacing_type
)
search_results = SearchQuerySet().models(CourseRun).all()
assert len(search_results) == 2
if boosted == 'a':
assert search_results[0].score > search_results[1].score
assert runa.title == search_results[0].title
else:
assert search_results[0].score > search_results[1].score
assert runb.title == search_results[0].title
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