Commit e0081ed0 by Ivan Ivic Committed by Ivan Ivic

Enable eligible program functionality

LEARNER-275
parent ba64068d
......@@ -713,7 +713,7 @@ class MinimalProgramSerializer(serializers.ModelSerializer):
model = Program
fields = (
'uuid', 'title', 'subtitle', 'type', 'status', 'marketing_slug', 'marketing_url', 'banner_image',
'courses', 'authoring_organizations', 'card_image_url',
'courses', 'authoring_organizations', 'card_image_url', 'is_program_eligible_for_one_click_purchase',
)
read_only_fields = ('uuid', 'marketing_url', 'banner_image')
......
......@@ -225,7 +225,7 @@ class MinimalCourseRunSerializerTests(TestCase):
'enrollment_end': json_date_format(course_run.enrollment_end),
'pacing_type': course_run.pacing_type,
'type': course_run.type,
'seats': SeatSerializer(course_run.seats, many=True).data
'seats': SeatSerializer(course_run.seats, many=True).data,
}
def test_data(self):
......@@ -601,6 +601,7 @@ class MinimalProgramSerializerTests(TestCase):
}).data,
'authoring_organizations': MinimalOrganizationSerializer(program.authoring_organizations, many=True).data,
'card_image_url': program.card_image_url,
'is_program_eligible_for_one_click_purchase': program.is_program_eligible_for_one_click_purchase
}
def test_data(self):
......
......@@ -10,6 +10,33 @@ from course_discovery.apps.course_metadata.publishers import ProgramPublisherExc
from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClientException
class ProgramEligibilityFilter(admin.SimpleListFilter):
title = _('eligible for one-click purchase')
parameter_name = 'eligible_for_one_click_purchase'
def lookups(self, request, model_admin): # pragma: no cover
return (
(1, _('Yes')),
(0, _('No'))
)
def queryset(self, request, queryset):
"""
The queryset can be filtered to contain programs that are eligible for
one click purchase or to exclude them.
"""
value = self.value()
if value is None:
return queryset
program_ids = set()
queryset = queryset.prefetch_related('courses__course_runs')
for program in queryset:
if program.is_program_eligible_for_one_click_purchase == bool(int(value)):
program_ids.add(program.id)
return queryset.filter(pk__in=program_ids)
class SeatInline(admin.TabularInline):
model = Seat
extra = 1
......@@ -81,7 +108,7 @@ class ProgramAdmin(admin.ModelAdmin):
form = ProgramAdminForm
inlines = [FaqsInline, IndividualEndorsementInline, CorporateEndorsementsInline]
list_display = ('id', 'uuid', 'title', 'type', 'partner', 'status',)
list_filter = ('partner', 'type', 'status',)
list_filter = ('partner', 'type', 'status', ProgramEligibilityFilter,)
ordering = ('uuid', 'title', 'status')
readonly_fields = ('uuid', 'custom_course_runs_display', 'excluded_course_runs',)
search_fields = ('uuid', 'title', 'marketing_slug')
......@@ -92,11 +119,9 @@ class ProgramAdmin(admin.ModelAdmin):
fields = (
'title', 'subtitle', 'status', 'type', 'partner', 'banner_image', 'banner_image_url', 'card_image_url',
'marketing_slug', 'overview', 'credit_redemption_overview', 'video', 'weeks_to_complete',
'min_hours_effort_per_week', 'max_hours_effort_per_week',
)
fields += (
'courses', 'order_courses_by_start_date', 'custom_course_runs_display', 'excluded_course_runs',
'authoring_organizations', 'credit_backing_organizations'
'min_hours_effort_per_week', 'max_hours_effort_per_week', 'courses', 'order_courses_by_start_date',
'custom_course_runs_display', 'excluded_course_runs', 'authoring_organizations',
'credit_backing_organizations', 'one_click_purchase_enabled',
)
fields += filter_horizontal
save_error = False
......
# -*- coding: utf-8 -*-
# Generated by Django 1.9.12 on 2017-04-05 12:06
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0050_person_profile_url'),
]
operations = [
migrations.AddField(
model_name='program',
name='one_click_purchase_enabled',
field=models.BooleanField(default=False, help_text='Allow courses in this program to be purchased in a single transaction'),
),
]
......@@ -9,6 +9,7 @@ import pytz
import waffle
from django.db import models, transaction
from django.db.models.query_utils import Q
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import AutoSlugField
......@@ -436,6 +437,37 @@ class CourseRun(TimeStampedModel):
return deadline
def enrollable_seats(self, types):
"""
Returns seats, of the given type(s), that can be enrolled in/purchased.
Arguments:
types (list of seat type names): Type of seats to limit the returned value to.
Returns:
List of Seats
"""
now = timezone.now()
enrollable_seats = []
if self.start and self.start > now:
return enrollable_seats
if self.end and now > self.end:
return enrollable_seats
if self.enrollment_start and self.enrollment_start > now:
return enrollable_seats
if self.enrollment_end and now > self.enrollment_end:
return enrollable_seats
for seat in self.seats.all():
if seat.type in types and (not seat.upgrade_deadline or now < seat.upgrade_deadline):
enrollable_seats.append(seat)
return enrollable_seats
@property
def program_types(self):
"""
......@@ -729,12 +761,42 @@ class Program(TimeStampedModel):
help_text=_('The description of credit redemption for courses in program'),
blank=True, null=True
)
one_click_purchase_enabled = models.BooleanField(
default=False,
help_text=_('Allow courses in this program to be purchased in a single transaction')
)
objects = ProgramQuerySet.as_manager()
def __str__(self):
return self.title
@property
def is_program_eligible_for_one_click_purchase(self):
"""
Checks if the program is eligible for one click purchase.
To pass the check the program must have one_click_purchase field enabled
and all its courses must contain only one course run and the remaining
not excluded course run must contain a purchasable seat.
"""
if not self.one_click_purchase_enabled:
return False
excluded_course_runs = set(self.excluded_course_runs.all())
applicable_seat_types = [seat_type.name.lower() for seat_type in self.type.applicable_seat_types.all()]
for course in self.courses.all():
course_runs = set(course.course_runs.all()) - excluded_course_runs
if len(course_runs) != 1:
return False
if not course_runs.pop().enrollable_seats(applicable_seat_types):
return False
return True
@cached_property
def _course_run_weeks_to_complete(self):
return [course_run.weeks_to_complete for course_run in self.course_runs
......
......@@ -13,9 +13,10 @@ from selenium.webdriver.support.wait import WebDriverWait
from course_discovery.apps.core.models import Partner
from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory
from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.course_metadata.admin import ProgramEligibilityFilter
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.forms import ProgramAdminForm
from course_discovery.apps.course_metadata.models import Program, ProgramType
from course_discovery.apps.course_metadata.models import Program, ProgramType, Seat, SeatType
from course_discovery.apps.course_metadata.tests import factories
......@@ -286,8 +287,8 @@ class ProgramAdminFunctionalTests(LiveServerTestCase):
'field-credit_redemption_overview', 'field-video', 'field-weeks_to_complete',
'field-min_hours_effort_per_week', 'field-max_hours_effort_per_week', 'field-courses',
'field-order_courses_by_start_date', 'field-custom_course_runs_display', 'field-excluded_course_runs',
'field-authoring_organizations', 'field-credit_backing_organizations', 'field-job_outlook_items',
'field-expected_learning_items',
'field-authoring_organizations', 'field-credit_backing_organizations', 'field-one_click_purchase_enabled',
'field-job_outlook_items', 'field-expected_learning_items',
]
self.assertEqual(actual, expected)
......@@ -349,3 +350,55 @@ class ProgramAdminFunctionalTests(LiveServerTestCase):
self.program = Program.objects.get(pk=self.program.pk)
self.assertEqual(self.program.title, title)
self.assertEqual(self.program.subtitle, subtitle)
class ProgramEligibilityFilterTests(TestCase):
""" Tests for Program Eligibility Filter class. """
parameter_name = 'eligible_for_one_click_purchase'
def test_queryset_method_returns_all_programs(self):
""" Verify that all programs pass the filter. """
verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED)
program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type])
program_filter = ProgramEligibilityFilter(None, {}, None, None)
course_run = factories.CourseRunFactory()
factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None)
one_click_purchase_eligible_program = factories.ProgramFactory(
type=program_type,
courses=[course_run.course],
one_click_purchase_enabled=True
)
one_click_purchase_ineligible_program = factories.ProgramFactory(courses=[course_run.course])
with self.assertNumQueries(1):
self.assertEqual(
list(program_filter.queryset({}, Program.objects.all())),
[one_click_purchase_ineligible_program, one_click_purchase_eligible_program]
)
def test_queryset_method_returns_eligible_programs(self):
""" Verify that one click purchase eligible programs pass the filter. """
verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED)
program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type])
program_filter = ProgramEligibilityFilter(None, {self.parameter_name: 1}, None, None)
course_run = factories.CourseRunFactory(end=None, enrollment_end=None,)
factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None)
one_click_purchase_eligible_program = factories.ProgramFactory(
type=program_type,
courses=[course_run.course],
one_click_purchase_enabled=True,
)
with self.assertNumQueries(10):
self.assertEqual(
list(program_filter.queryset({}, Program.objects.all())),
[one_click_purchase_eligible_program]
)
def test_queryset_method_returns_ineligible_programs(self):
""" Verify programs ineligible for one-click purchase do not pass the filter. """
program_filter = ProgramEligibilityFilter(None, {self.parameter_name: 0}, None, None)
one_click_purchase_ineligible_program = factories.ProgramFactory(one_click_purchase_enabled=False)
with self.assertNumQueries(4):
self.assertEqual(
list(program_filter.queryset({}, Program.objects.all())),
[one_click_purchase_ineligible_program]
)
......@@ -68,6 +68,17 @@ class CourseRunTests(TestCase):
super(CourseRunTests, self).setUp()
self.course_run = factories.CourseRunFactory()
def test_enrollable_seats(self):
""" Verify the expected seats get returned. """
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)
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)
self.assertEqual(
course_run.enrollable_seats([Seat.VERIFIED, Seat.PROFESSIONAL]),
[verified_seat, professional_seat]
)
def test_str(self):
""" Verify casting an instance to a string returns a string containing the key and title. """
course_run = self.course_run
......@@ -405,6 +416,146 @@ class ProgramTests(MarketingSitePublisherTestMixin):
return factories.ProgramFactory(type=program_type, courses=[course_run.course])
def assert_one_click_purchase_ineligible_program(self, start=None, end=None, enrollment_start=None,
enrollment_end=None, seat_type=Seat.VERIFIED,
upgrade_deadline=None, one_click_purchase_enabled=True,
excluded_course_runs=None, program_type=None,):
course_run = factories.CourseRunFactory(
start=start, end=end, enrollment_start=enrollment_start, enrollment_end=enrollment_end
)
factories.SeatFactory(course_run=course_run, type=seat_type, upgrade_deadline=upgrade_deadline)
program = factories.ProgramFactory(
courses=[course_run.course],
excluded_course_runs=excluded_course_runs,
one_click_purchase_enabled=one_click_purchase_enabled,
type=program_type,
)
self.assertFalse(program.is_program_eligible_for_one_click_purchase)
def test_one_click_purchase_eligible(self):
""" Verify that program is one click purchase eligible. """
verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED)
program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type])
# Program has one_click_purchase_enabled set to True,
# all courses have one course run, all course runs have
# verified seat types
courses = []
for __ in range(3):
course_run = factories.CourseRunFactory(
end=None,
enrollment_end=None
)
factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None)
courses.append(course_run.course)
program = factories.ProgramFactory(
courses=courses,
one_click_purchase_enabled=True,
type=program_type,
)
self.assertTrue(program.is_program_eligible_for_one_click_purchase)
# Program has one_click_purchase_enabled set to True,
# course has all course runs excluded except one which
# has verified seat type
course_run = factories.CourseRunFactory(
end=None,
enrollment_end=None
)
factories.SeatFactory(course_run=course_run, type=Seat.VERIFIED, upgrade_deadline=None)
course = course_run.course
excluded_course_runs = [
factories.CourseRunFactory(course=course),
factories.CourseRunFactory(course=course)
]
program = factories.ProgramFactory(
courses=[course],
excluded_course_runs=excluded_course_runs,
one_click_purchase_enabled=True,
type=program_type,
)
self.assertTrue(program.is_program_eligible_for_one_click_purchase)
def test_one_click_purchase_ineligible(self):
""" Verify that program is one click purchase ineligible. """
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
tomorrow = datetime.datetime.now() + datetime.timedelta(days=1)
verified_seat_type, __ = SeatType.objects.get_or_create(name=Seat.VERIFIED)
program_type = factories.ProgramTypeFactory(applicable_seat_types=[verified_seat_type])
# Program has one_click_purchase_enabled set to False and
# every course has one course run
self.assert_one_click_purchase_ineligible_program(
one_click_purchase_enabled=False,
program_type=program_type,
)
# Program has one_click_purchase_enabled set to True and
# one course has two course runs
course_run = factories.CourseRunFactory(end=None, enrollment_end=None)
factories.CourseRunFactory(end=None, enrollment_end=None, course=course_run.course)
factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None)
program = factories.ProgramFactory(
courses=[course_run.course],
one_click_purchase_enabled=True,
type=program_type,
)
self.assertFalse(program.is_program_eligible_for_one_click_purchase)
# Program has one_click_purchase_enabled set to True and
# one course with one course run excluded from the program
course_run = factories.CourseRunFactory(end=None, enrollment_end=None)
factories.SeatFactory(course_run=course_run, type='verified', upgrade_deadline=None)
program = factories.ProgramFactory(
courses=[course_run.course],
one_click_purchase_enabled=True,
excluded_course_runs=[course_run],
type=program_type,
)
self.assertFalse(program.is_program_eligible_for_one_click_purchase)
# Program has one_click_purchase_enabled set to True, one course
# with one course run, course run start date not passed
self.assert_one_click_purchase_ineligible_program(
start=tomorrow,
program_type=program_type,
)
# Program has one_click_purchase_enabled set to True, one course
# with one course run, course run end date passed
self.assert_one_click_purchase_ineligible_program(
end=yesterday,
program_type=program_type,
)
# Program has one_click_purchase_enabled set to True, one course
# with one course run, course run enrollment start date not passed
self.assert_one_click_purchase_ineligible_program(
enrollment_start=tomorrow,
program_type=program_type,
)
# Program has one_click_purchase_enabled set to True, one course
# with one course run, course run enrollment end date passed
self.assert_one_click_purchase_ineligible_program(
enrollment_end=yesterday,
program_type=program_type,
)
# Program has one_click_purchase_enabled set to True, one course
# with one course run, seat upgrade deadline passed
self.assert_one_click_purchase_ineligible_program(
upgrade_deadline=yesterday,
program_type=program_type,
)
# Program has one_click_purchase_enabled set to True, one course
# with one course run, seat type is not purchasable
self.assert_one_click_purchase_ineligible_program(
seat_type='incorrect',
program_type=program_type,
)
def test_str(self):
"""Verify that a program is properly converted to a str."""
self.assertEqual(str(self.program), self.program.title)
......
......@@ -7,14 +7,14 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-06 12:30+0500\n"
"POT-Creation-Date: 2017-04-07 11:51+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: \n"
#: apps/api/filters.py
#, python-brace-format
......@@ -201,6 +201,19 @@ msgid "Partners"
msgstr ""
#: apps/course_metadata/admin.py
msgid "eligible for one-click purchase"
msgstr ""
#: apps/course_metadata/admin.py apps/publisher/forms.py
#: templates/publisher/course_run_detail/_preview_accept_popup.html
msgid "Yes"
msgstr ""
#: apps/course_metadata/admin.py apps/publisher/forms.py
msgid "No"
msgstr ""
#: apps/course_metadata/admin.py
msgid "Included course runs"
msgstr ""
......@@ -422,6 +435,10 @@ msgstr ""
msgid "The description of credit redemption for courses in program"
msgstr ""
#: apps/course_metadata/models.py
msgid "Allow courses in this program to be purchased in a single transaction"
msgstr ""
#: apps/course_metadata/views.py
msgid "Change program excluded course runs"
msgstr ""
......@@ -599,15 +616,6 @@ msgid "Instructor"
msgstr ""
#: apps/publisher/forms.py
#: templates/publisher/course_run_detail/_preview_accept_popup.html
msgid "Yes"
msgstr ""
#: apps/publisher/forms.py
msgid "No"
msgstr ""
#: apps/publisher/forms.py
msgid "Pacing"
msgstr ""
......
......@@ -7,14 +7,14 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-06 12:30+0500\n"
"POT-Creation-Date: 2017-04-07 11:51+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: \n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: apps/api/filters.py
......@@ -242,6 +242,19 @@ msgid "Partners"
msgstr "Pärtnérs Ⱡ'σяєм ιρѕυм ∂#"
#: apps/course_metadata/admin.py
msgid "eligible for one-click purchase"
msgstr "élïgïßlé för öné-çlïçk pürçhäsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
#: apps/course_metadata/admin.py apps/publisher/forms.py
#: templates/publisher/course_run_detail/_preview_accept_popup.html
msgid "Yes"
msgstr "Ýés Ⱡ'σяєм#"
#: apps/course_metadata/admin.py apps/publisher/forms.py
msgid "No"
msgstr "Nö Ⱡ'σя#"
#: apps/course_metadata/admin.py
msgid "Included course runs"
msgstr "Ìnçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
......@@ -531,6 +544,12 @@ msgstr ""
"Thé désçrïptïön öf çrédït rédémptïön för çöürsés ïn prögräm Ⱡ'σяєм ιρѕυм "
"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: apps/course_metadata/models.py
msgid "Allow courses in this program to be purchased in a single transaction"
msgstr ""
"Àllöw çöürsés ïn thïs prögräm tö ßé pürçhäséd ïn ä sïnglé tränsäçtïön Ⱡ'σяєм"
" ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: apps/course_metadata/views.py
msgid "Change program excluded course runs"
msgstr ""
......@@ -727,15 +746,6 @@ msgid "Instructor"
msgstr "Ìnstrüçtör Ⱡ'σяєм ιρѕυм ∂σłσ#"
#: apps/publisher/forms.py
#: templates/publisher/course_run_detail/_preview_accept_popup.html
msgid "Yes"
msgstr "Ýés Ⱡ'σяєм#"
#: apps/publisher/forms.py
msgid "No"
msgstr "Nö Ⱡ'σя#"
#: apps/publisher/forms.py
msgid "Pacing"
msgstr "Päçïng Ⱡ'σяєм ιρѕυ#"
......
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