Commit 5cd39185 by Douglas Hall Committed by Douglas Hall

Add indexed has_enrollable_seats field to CourseRun model.

This field will make it possible for clients of the search/all
endpoint to filter CourseRun results to those that have enrollable
seats.

ENT-687
parent 9f228395
...@@ -56,7 +56,7 @@ COURSE_RUN_SEARCH_FIELDS = ( ...@@ -56,7 +56,7 @@ COURSE_RUN_SEARCH_FIELDS = (
'enrollment_end', 'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org', 'enrollment_end', 'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org',
'number', 'seat_types', 'image_url', 'type', 'level_type', 'availability', 'published', 'partner', 'program_types', 'number', 'seat_types', 'image_url', 'type', 'level_type', 'availability', 'published', 'partner', 'program_types',
'authoring_organization_uuids', 'subject_uuids', 'staff_uuids', 'mobile_available', 'logo_image_urls', 'authoring_organization_uuids', 'subject_uuids', 'staff_uuids', 'mobile_available', 'logo_image_urls',
'aggregation_key', 'min_effort', 'max_effort', 'weeks_to_complete', 'aggregation_key', 'min_effort', 'max_effort', 'weeks_to_complete', 'has_enrollable_seats',
) )
PROGRAM_FACET_FIELD_OPTIONS = { PROGRAM_FACET_FIELD_OPTIONS = {
......
...@@ -611,7 +611,7 @@ class MinimalProgramSerializerTests(TestCase): ...@@ -611,7 +611,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()) CourseRunFactory.create_batch(2, course=course, staff=[person], start=datetime.datetime.now(pytz.UTC))
return ProgramFactory( return ProgramFactory(
courses=courses, courses=courses,
...@@ -1248,6 +1248,7 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase): ...@@ -1248,6 +1248,7 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase):
'subject_uuids': get_uuids(course_run.subjects.all()), 'subject_uuids': get_uuids(course_run.subjects.all()),
'staff_uuids': get_uuids(course_run.staff.all()), 'staff_uuids': get_uuids(course_run.staff.all()),
'aggregation_key': 'courserun:{}'.format(course_run.course.key), 'aggregation_key': 'courserun:{}'.format(course_run.course.key),
'has_enrollable_seats': course_run.has_enrollable_seats,
} }
assert serializer.data == expected assert serializer.data == expected
......
...@@ -3,6 +3,7 @@ import json ...@@ -3,6 +3,7 @@ import json
import urllib.parse import urllib.parse
import ddt import ddt
import pytz
from django.urls import reverse from django.urls import reverse
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
...@@ -173,7 +174,7 @@ class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT ...@@ -173,7 +174,7 @@ class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
def test_availability_faceting(self): def test_availability_faceting(self):
""" Verify the endpoint returns availability facets with the results. """ """ Verify the endpoint returns availability facets with the results. """
now = datetime.datetime.utcnow() now = datetime.datetime.now(pytz.UTC)
archived = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=2), archived = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=2),
end=now - datetime.timedelta(weeks=1), status=CourseRunStatus.Published) end=now - datetime.timedelta(weeks=1), status=CourseRunStatus.Published)
current = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=2), current = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=2),
...@@ -401,7 +402,7 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT ...@@ -401,7 +402,7 @@ class AggregateSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchT
@ddt.data('start', '-start') @ddt.data('start', '-start')
def test_results_ordered_by_start_date(self, ordering): def test_results_ordered_by_start_date(self, ordering):
""" Verify the search results can be ordered by start date """ """ Verify the search results can be ordered by start date """
now = datetime.datetime.utcnow() now = datetime.datetime.now(pytz.UTC)
archived = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=2)) archived = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=2))
current = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=1)) current = CourseRunFactory(course__partner=self.partner, start=now - datetime.timedelta(weeks=1))
starting_soon = CourseRunFactory(course__partner=self.partner, start=now + datetime.timedelta(weeks=3)) starting_soon = CourseRunFactory(course__partner=self.partner, start=now + datetime.timedelta(weeks=3))
......
...@@ -501,7 +501,7 @@ class CourseRun(TimeStampedModel): ...@@ -501,7 +501,7 @@ class CourseRun(TimeStampedModel):
return deadline return deadline
def enrollable_seats(self, types): def enrollable_seats(self, types=None):
""" """
Returns seats, of the given type(s), that can be enrolled in/purchased. Returns seats, of the given type(s), that can be enrolled in/purchased.
...@@ -523,6 +523,7 @@ class CourseRun(TimeStampedModel): ...@@ -523,6 +523,7 @@ class CourseRun(TimeStampedModel):
if self.enrollment_end and now > self.enrollment_end: if self.enrollment_end and now > self.enrollment_end:
return enrollable_seats return enrollable_seats
types = types or Seat.SEAT_TYPES
for seat in self.seats.all(): for seat in self.seats.all():
if seat.type in types and (not seat.upgrade_deadline or now < seat.upgrade_deadline): if seat.type in types and (not seat.upgrade_deadline or now < seat.upgrade_deadline):
enrollable_seats.append(seat) enrollable_seats.append(seat)
...@@ -530,6 +531,13 @@ class CourseRun(TimeStampedModel): ...@@ -530,6 +531,13 @@ class CourseRun(TimeStampedModel):
return enrollable_seats return enrollable_seats
@property @property
def has_enrollable_seats(self):
"""
Return a boolean indicating whether or not enrollable Seats are available for this CourseRun.
"""
return len(self.enrollable_seats()) > 0
@property
def image_url(self): def image_url(self):
return self.course.image_url return self.course.image_url
...@@ -702,6 +710,8 @@ class Seat(TimeStampedModel): ...@@ -702,6 +710,8 @@ class Seat(TimeStampedModel):
PROFESSIONAL = 'professional' PROFESSIONAL = 'professional'
CREDIT = 'credit' CREDIT = 'credit'
SEAT_TYPES = [HONOR, AUDIT, VERIFIED, PROFESSIONAL, CREDIT]
# Seat types that may not be purchased without first purchasing another Seat type. # 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. # EX: 'credit' seats may not be purchased without first purchasing a 'verified' Seat.
SEATS_WITH_PREREQUISITES = [CREDIT] SEATS_WITH_PREREQUISITES = [CREDIT]
......
...@@ -170,6 +170,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -170,6 +170,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
has_enrollable_paid_seats = indexes.BooleanField(null=False) has_enrollable_paid_seats = indexes.BooleanField(null=False)
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)
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.
......
...@@ -5,6 +5,7 @@ from decimal import Decimal ...@@ -5,6 +5,7 @@ from decimal import Decimal
import ddt import ddt
import mock import mock
import pytest import pytest
import pytz
from dateutil.parser import parse from dateutil.parser import parse
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
...@@ -71,19 +72,26 @@ class CourseRunTests(TestCase): ...@@ -71,19 +72,26 @@ class CourseRunTests(TestCase):
course_run = factories.CourseRunFactory(start=None, end=None, enrollment_start=None, enrollment_end=None) course_run = factories.CourseRunFactory(start=None, end=None, enrollment_start=None, enrollment_end=None)
verified_seat = factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None) verified_seat = factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None)
professional_seat = factories.SeatFactory(course_run=course_run, type=Seat.PROFESSIONAL, upgrade_deadline=None) professional_seat = factories.SeatFactory(course_run=course_run, type=Seat.PROFESSIONAL, upgrade_deadline=None)
factories.SeatFactory(course_run=course_run, type=Seat.HONOR, upgrade_deadline=None) honor_seat = factories.SeatFactory(course_run=course_run, type=Seat.HONOR, upgrade_deadline=None)
self.assertEqual( assert course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]) == [verified_seat, professional_seat]
course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]),
[verified_seat, professional_seat]
)
# The method should not care about the course run's start date. # The method should not care about the course run's start date.
course_run.start = datetime.datetime.utcnow() + datetime.timedelta(days=1) course_run.start = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1)
course_run.save() course_run.save()
self.assertEqual( assert course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]) == [verified_seat, professional_seat]
course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]),
[verified_seat, professional_seat] # Enrollable seats of any type should be returned when no type parameter is specified.
) assert course_run.enrollable_seats() == [verified_seat, professional_seat, honor_seat]
def test_has_enrollable_seats(self):
""" Verify the expected value of has_enrollable_seats is returned. """
course_run = factories.CourseRunFactory(start=None, end=None, enrollment_start=None, enrollment_end=None)
factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None)
assert course_run.has_enrollable_seats is True
course_run.end = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
course_run.save()
assert course_run.has_enrollable_seats is False
def test_str(self): def test_str(self):
""" Verify casting an instance to a string returns a string containing the key and title. """ """ Verify casting an instance to a string returns a string containing the key and title. """
...@@ -564,8 +572,8 @@ class ProgramTests(TestCase): ...@@ -564,8 +572,8 @@ class ProgramTests(TestCase):
def test_one_click_purchase_ineligible(self): def test_one_click_purchase_ineligible(self):
""" Verify that program is one click purchase ineligible. """ """ Verify that program is one click purchase ineligible. """
yesterday = datetime.datetime.utcnow() - datetime.timedelta(days=1) yesterday = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1)
tomorrow = datetime.datetime.utcnow() + datetime.timedelta(days=1) tomorrow = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1)
verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED) verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED)
program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type]) program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type])
...@@ -786,7 +794,7 @@ class ProgramTests(TestCase): ...@@ -786,7 +794,7 @@ class ProgramTests(TestCase):
course_run.course.save() course_run.course.save()
day_separation = 1 day_separation = 1
now = datetime.datetime.utcnow() now = datetime.datetime.now(pytz.UTC)
for course_run in course_runs_same_course: for course_run in course_runs_same_course:
if set_all_dates or day_separation < 2: if set_all_dates or day_separation < 2:
......
import datetime import datetime
import urllib.parse import urllib.parse
import pytz
from django.urls import reverse from django.urls import reverse
from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase
...@@ -78,7 +79,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin, ...@@ -78,7 +79,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin,
def test_query_facet_response(self): def test_query_facet_response(self):
""" Verify that query facets are included in the response and that they are properly formatted.""" """ Verify that query facets are included in the response and that they are properly formatted."""
now = datetime.datetime.now() now = datetime.datetime.now(pytz.UTC)
current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1)) current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1))
starting_soon = (now + datetime.timedelta(days=1), now + datetime.timedelta(days=2)) starting_soon = (now + datetime.timedelta(days=1), now + datetime.timedelta(days=2))
upcoming = (now + datetime.timedelta(days=61), now + datetime.timedelta(days=62)) upcoming = (now + datetime.timedelta(days=61), now + datetime.timedelta(days=62))
...@@ -130,7 +131,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin, ...@@ -130,7 +131,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin,
def test_response_with_search_query(self): def test_response_with_search_query(self):
""" Verify that the response is accurate when a search query is passed.""" """ Verify that the response is accurate when a search query is passed."""
now = datetime.datetime.now() now = datetime.datetime.now(pytz.UTC)
current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1)) current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1))
course = CourseFactory(partner=self.partner) course = CourseFactory(partner=self.partner)
...@@ -189,7 +190,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin, ...@@ -189,7 +190,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin,
def test_selected_field_facet(self): def test_selected_field_facet(self):
""" Verify that the response is accurate when a field facet is selected.""" """ Verify that the response is accurate when a field facet is selected."""
now = datetime.datetime.now() now = datetime.datetime.now(pytz.UTC)
current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1)) current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1))
archived = (now - datetime.timedelta(days=2), now - datetime.timedelta(days=1)) archived = (now - datetime.timedelta(days=2), now - datetime.timedelta(days=1))
...@@ -226,7 +227,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin, ...@@ -226,7 +227,7 @@ class DistinctCountsAggregateSearchViewSetTests(SerializationMixin, LoginMixin,
def test_selected_query_facet(self): def test_selected_query_facet(self):
""" Verify that the response is accurate when a query facet is selected.""" """ Verify that the response is accurate when a query facet is selected."""
now = datetime.datetime.now() now = datetime.datetime.now(pytz.UTC)
current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1)) current = (now - datetime.timedelta(days=1), now + datetime.timedelta(days=1))
archived = (now - datetime.timedelta(days=2), now - datetime.timedelta(days=1)) archived = (now - datetime.timedelta(days=2), now - datetime.timedelta(days=1))
......
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