From f72cf800e1bfae5345776da4d03e804603c601cb Mon Sep 17 00:00:00 2001 From: Anthony Mangano Date: Fri, 17 Nov 2017 17:08:32 -0500 Subject: [PATCH] Consider user entitlements and use entitlement products in bundle one-click purchase --- common/djangoapps/course_modes/models.py | 8 +- common/djangoapps/entitlements/models.py | 7 + .../djangoapps/catalog/tests/factories.py | 14 +- .../djangoapps/programs/tests/test_utils.py | 203 +++++++++++++++--- openedx/core/djangoapps/programs/utils.py | 106 ++++++--- 5 files changed, 274 insertions(+), 64 deletions(-) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 69a352ab62..1f74070e05 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -136,10 +136,10 @@ class CourseMode(models.Model): HONOR = 'honor' PROFESSIONAL = 'professional' - VERIFIED = "verified" - AUDIT = "audit" - NO_ID_PROFESSIONAL_MODE = "no-id-professional" - CREDIT_MODE = "credit" + VERIFIED = 'verified' + AUDIT = 'audit' + NO_ID_PROFESSIONAL_MODE = 'no-id-professional' + CREDIT_MODE = 'credit' DEFAULT_MODE = Mode( settings.COURSE_MODE_DEFAULTS['slug'], diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index 09b03e5e6c..eb43bd6b32 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -24,3 +24,10 @@ class CourseEntitlement(TimeStampedModel): help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.' ) order_number = models.CharField(max_length=128, null=True) + + @property + def expired_at_datetime(self): + """ + Getter to be used instead of expired_at because of the conditional check and update + """ + return self.expired_at diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index 23efd57775..1db3054450 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -8,6 +8,7 @@ from faker import Faker fake = Faker() +VERIFIED_MODE = 'verified' def generate_instances(factory_class, count=3): @@ -103,10 +104,18 @@ class SeatFactory(DictFactoryBase): currency = 'USD' price = factory.Faker('random_int') sku = factory.LazyFunction(generate_seat_sku) - type = 'verified' + type = VERIFIED_MODE upgrade_deadline = factory.LazyFunction(generate_zulu_datetime) +class EntitlementFactory(DictFactoryBase): + currency = 'USD' + price = factory.Faker('random_int') + sku = factory.LazyFunction(generate_seat_sku) + mode = VERIFIED_MODE + expires = None + + class CourseRunFactory(DictFactoryBase): eligible_for_financial_aid = True end = factory.LazyFunction(generate_zulu_datetime) @@ -121,7 +130,7 @@ class CourseRunFactory(DictFactoryBase): start = factory.LazyFunction(generate_zulu_datetime) status = 'published' title = factory.Faker('catch_phrase') - type = 'verified' + type = VERIFIED_MODE uuid = factory.Faker('uuid4') content_language = 'en' max_effort = 4 @@ -130,6 +139,7 @@ class CourseRunFactory(DictFactoryBase): class CourseFactory(DictFactoryBase): course_runs = factory.LazyFunction(partial(generate_instances, CourseRunFactory)) + entitlements = factory.LazyFunction(partial(generate_instances, EntitlementFactory)) image = ImageFactory() key = factory.LazyFunction(generate_course_key) owners = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1)) diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index e853d3397a..bd581cf74c 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -16,6 +16,7 @@ from nose.plugins.attrib import attr from pytz import utc from course_modes.models import CourseMode +from entitlements.tests.factories import CourseEntitlementFactory from lms.djangoapps.certificates.api import MODES from lms.djangoapps.commerce.tests.test_utils import update_commerce_config from lms.djangoapps.commerce.utils import EcommerceService @@ -23,6 +24,7 @@ from lms.djangoapps.grades.tests.utils import mock_passing_grade from openedx.core.djangoapps.catalog.tests.factories import ( CourseFactory, CourseRunFactory, + EntitlementFactory, ProgramFactory, SeatFactory, generate_course_run_key @@ -63,7 +65,7 @@ class TestProgramProgressMeter(TestCase): def _create_enrollments(self, *course_run_ids): """Variadic helper used to create course run enrollments.""" for course_run_id in course_run_ids: - CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode='verified') + CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode=CourseMode.VERIFIED) def _assert_progress(self, meter, *progresses): """Variadic helper used to verify progress calculations.""" @@ -225,22 +227,22 @@ class TestProgramProgressMeter(TestCase): course_run_key = generate_course_run_key() now = datetime.datetime.now(utc) upgrade_deadline = None if not offset else str(now + datetime.timedelta(days=offset)) - required_seat = SeatFactory(type='verified', upgrade_deadline=upgrade_deadline) - enrolled_seat = SeatFactory(type='audit') + required_seat = SeatFactory(type=CourseMode.VERIFIED, upgrade_deadline=upgrade_deadline) + enrolled_seat = SeatFactory(type=CourseMode.AUDIT) seats = [required_seat, enrolled_seat] data = [ ProgramFactory( courses=[ CourseFactory(course_runs=[ - CourseRunFactory(key=course_run_key, type='verified', seats=seats), + CourseRunFactory(key=course_run_key, type=CourseMode.VERIFIED, seats=seats), ]), ] ) ] mock_get_programs.return_value = data - CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode='audit') + CourseEnrollmentFactory(user=self.user, course_id=course_run_key, mode=CourseMode.AUDIT) meter = ProgramProgressMeter(self.site, self.user) @@ -537,7 +539,9 @@ class TestProgramProgressMeter(TestCase): Verify that the method can find course run certificates when not mocked out. """ mock_get_certificates_for_user.return_value = [ - self._make_certificate_result(status='downloadable', type='verified', course_key='downloadable-course'), + self._make_certificate_result( + status='downloadable', type=CourseMode.VERIFIED, course_key='downloadable-course' + ), self._make_certificate_result(status='generating', type='honor', course_key='generating-course'), self._make_certificate_result(status='unknown', course_key='unknown-course'), ] @@ -546,7 +550,7 @@ class TestProgramProgressMeter(TestCase): self.assertEqual( meter.completed_course_runs, [ - {'course_run_id': 'downloadable-course', 'type': 'verified'}, + {'course_run_id': 'downloadable-course', 'type': CourseMode.VERIFIED}, {'course_run_id': 'generating-course', 'type': 'honor'}, ] ) @@ -558,9 +562,10 @@ class TestProgramProgressMeter(TestCase): Verify that 'no-id-professional' certificates are treated as if they were 'professional' certificates when determining program completion. """ - # Create serialized course runs like the ones we expect to receive from - # the discovery service's API. These runs are of type 'professional'. - course_runs = CourseRunFactory.create_batch(2, type='professional') + # Create serialized course runs like the ones we expect to receive from the discovery service's API. + # These runs are of type 'professional' because there is no seat type for no-id-professional; + # it uses professional as the seat type instead. + course_runs = CourseRunFactory.create_batch(2, type=CourseMode.PROFESSIONAL) program = ProgramFactory(courses=[CourseFactory(course_runs=course_runs)]) mock_get_programs.return_value = [program] @@ -571,7 +576,9 @@ class TestProgramProgressMeter(TestCase): # Grant a 'no-id-professional' certificate for one of the course runs, # thereby completing the program. mock_get_certificates_for_user.return_value = [ - self._make_certificate_result(status='downloadable', type='no-id-professional', course_key=course_runs[0]['key']) + self._make_certificate_result( + status='downloadable', type=CourseMode.NO_ID_PROFESSIONAL_MODE, course_key=course_runs[0]['key'] + ) ] # Verify that the program is complete. @@ -592,7 +599,7 @@ class TestProgramProgressMeter(TestCase): mock_get_programs.return_value = [program] self._create_enrollments(course_run_key) meter = ProgramProgressMeter(self.site, self.user) - mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': 'verified'}] + mock_completed_course_runs.return_value = [{'course_run_id': course_run_key, 'type': CourseMode.VERIFIED}] self.assertEqual(meter._is_course_complete(course), True) def test_course_grade_results(self, mock_get_programs): @@ -628,7 +635,7 @@ class TestProgramProgressMeter(TestCase): self.assertEqual(meter.progress(count_only=False), expected) -def _create_course(self, course_price, course_run_count=1): +def _create_course(self, course_price, course_run_count=1, make_entitlement=False): """ Creates the course in mongo and update it with the instructor data. Also creates catalog course with respect to course run. @@ -646,8 +653,9 @@ def _create_course(self, course_price, course_run_count=1): run = CourseRunFactory(key=unicode(course.id), seats=[SeatFactory(price=course_price)]) course_runs.append(run) + entitlements = [EntitlementFactory()] if make_entitlement else [] - return CourseFactory(course_runs=course_runs) + return CourseFactory(course_runs=course_runs, entitlements=entitlements) @ddt.ddt @@ -879,12 +887,12 @@ class TestProgramDataExtender(ModuleStoreTestCase): course1 = _create_course(self, self.course_price) course2 = _create_course(self, self.course_price) - CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='verified') - CourseEnrollmentFactory(user=self.user, course_id=course2['course_runs'][0]['key'], mode='audit') + CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED) + CourseEnrollmentFactory(user=self.user, course_id=course2['course_runs'][0]['key'], mode=CourseMode.AUDIT) program2 = ProgramFactory( courses=[course1, course2], is_program_eligible_for_one_click_purchase=True, - applicable_seat_types=['verified'], + applicable_seat_types=[CourseMode.VERIFIED], ) data = ProgramDataExtender(program2, self.user).extend() self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) @@ -897,12 +905,12 @@ class TestProgramDataExtender(ModuleStoreTestCase): """ course1 = _create_course(self, self.course_price, course_run_count=2) course2 = _create_course(self, self.course_price) - CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='verified') + CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED) course1['course_runs'][0]['status'] = 'unpublished' program2 = ProgramFactory( courses=[course1, course2], is_program_eligible_for_one_click_purchase=True, - applicable_seat_types=['verified'], + applicable_seat_types=[CourseMode.VERIFIED], ) data = ProgramDataExtender(program2, self.user).extend() self.assertEqual(len(data['skus']), 1) @@ -915,12 +923,13 @@ class TestProgramDataExtender(ModuleStoreTestCase): This test is primarily for the case of no-id-professional enrollment modes """ course1 = _create_course(self, self.course_price) - CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode='no-id-professional') + CourseEnrollmentFactory( + user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.NO_ID_PROFESSIONAL_MODE + ) program2 = ProgramFactory( courses=[course1], is_program_eligible_for_one_click_purchase=True, - applicable_seat_types=['professional'], # There is no seat type for no-id-professional, it - # instead uses professional + applicable_seat_types=[CourseMode.PROFESSIONAL] ) data = ProgramDataExtender(program2, self.user).extend() self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) @@ -938,7 +947,7 @@ class TestProgramDataExtender(ModuleStoreTestCase): key=str(ModuleStoreCourseFactory().id), status='published' ) - course = CourseFactory(course_runs=[course_run_1, course_run_2]) + course = CourseFactory(course_runs=[course_run_1, course_run_2], entitlements=[]) program = ProgramFactory( courses=[ CourseFactory(course_runs=[ @@ -956,7 +965,7 @@ class TestProgramDataExtender(ModuleStoreTestCase): ]) ], is_program_eligible_for_one_click_purchase=True, - applicable_seat_types=['verified'] + applicable_seat_types=[CourseMode.VERIFIED] ) data = ProgramDataExtender(program, self.user).extend() @@ -967,6 +976,147 @@ class TestProgramDataExtender(ModuleStoreTestCase): self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + def test_learner_eligibility_for_one_click_purchase_entitlement_products(self): + """ + Learner should be eligible for one click purchase if: + - program is eligible for one click purchase + - There are remaining unpurchased courses with entitlement products + """ + course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + expected_skus = set([course1['entitlements'][0]['sku'], course2['entitlements'][0]['sku']]) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(set(data['skus']), expected_skus) + + def test_learner_eligibility_for_one_click_purchase_ineligible_program(self): + """ + Learner should not be eligible for one click purchase if the program is not eligible for one click purchase + """ + course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=False, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(data['skus'], []) + + def test_learner_eligibility_for_one_click_purchase_user_entitlements(self): + """ + Learner should be eligibile for one click purchase if they hold an entitlement in one or more courses + in the program and there are remaining unpurchased courses in the program with entitlement products. + """ + course1 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + CourseEntitlementFactory(user=self.user, course_uuid=course1['uuid'], mode=CourseMode.VERIFIED) + expected_skus = set([course2['entitlements'][0]['sku']]) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(set(data['skus']), expected_skus) + + def test_all_courses_owned(self): + """ + Learner should not be eligible for one click purchase if they hold entitlements in all courses in the program. + """ + course1 = _create_course(self, self.course_price, make_entitlement=True) + course2 = _create_course(self, self.course_price) + CourseEntitlementFactory(user=self.user, course_uuid=course1['uuid'], mode=CourseMode.VERIFIED) + CourseEntitlementFactory(user=self.user, course_uuid=course2['uuid'], mode=CourseMode.VERIFIED) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(data['skus'], []) + + def test_entitlement_product_wrong_mode(self): + """ + Learner should not be eligible for one click purchase if the only entitlement product + for a course in the program is not in an applicable mode, and that course has multiple course runs. + """ + course1 = _create_course(self, self.course_price) + course2 = _create_course(self, self.course_price, course_run_count=2) + course2['entitlements'].append(EntitlementFactory(mode=CourseMode.PROFESSIONAL)) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(data['skus'], []) + + def test_second_entitlement_product_wrong_mode(self): + """ + Learner should be eligible for one click purchase if a course has multiple entitlement products + and at least one of them is in an applicable mode, even if one is not in an applicable mode. + """ + course1 = _create_course(self, self.course_price) + course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + # The above statement makes a verfied entitlement for the course, which is an applicable seat type + # and the statement below makes a professional entitlement for the same course, which is not applicable + course2['entitlements'].append(EntitlementFactory(mode=CourseMode.PROFESSIONAL)) + expected_skus = set([course1['course_runs'][0]['seats'][0]['sku'], course2['entitlements'][0]['sku']]) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(set(data['skus']), expected_skus) + + def test_entitlement_product_and_user_enrollment(self): + """ + Learner should be eligible for one click purchase if they hold an enrollment + but not an entitlement in a course for which there exists an entitlement product. + """ + course1 = _create_course(self, self.course_price, make_entitlement=True) + course2 = _create_course(self, self.course_price) + expected_skus = set([course2['course_runs'][0]['seats'][0]['sku']]) + CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(set(data['skus']), expected_skus) + + def test_user_enrollment_with_other_course_entitlement_product(self): + """ + Learner should be eligible for one click purchase if they hold an enrollment in one course of the program + and there is an entitlement product for another course in the program. + """ + course1 = _create_course(self, self.course_price, course_run_count=2) + course2 = _create_course(self, self.course_price, course_run_count=2, make_entitlement=True) + CourseEnrollmentFactory(user=self.user, course_id=course1['course_runs'][0]['key'], mode=CourseMode.VERIFIED) + expected_skus = set([course2['entitlements'][0]['sku']]) + program = ProgramFactory( + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=[CourseMode.VERIFIED, CourseMode.PROFESSIONAL], + ) + data = ProgramDataExtender(program, self.user).extend() + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + self.assertEqual(set(data['skus']), expected_skus) + @skip_unless_lms @mock.patch(UTILS_MODULE + '.get_credentials') @@ -1095,7 +1245,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): self.number_of_courses = 2 self.program = ProgramFactory( courses=[_create_course(self, self.course_price) for __ in range(self.number_of_courses)], - applicable_seat_types=['verified'] + applicable_seat_types=[CourseMode.VERIFIED] ) def _prepare_program_for_discounted_price_calculation_endpoint(self): @@ -1212,8 +1362,9 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): body=json.dumps(mock_discount_data), content_type='application/json' ) + user = AnonymousUserFactory() - data = ProgramMarketingDataExtender(self.program, AnonymousUserFactory()).extend() + data = ProgramMarketingDataExtender(self.program, user).extend() self._update_discount_data(mock_discount_data) self.assertEqual( diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index ba065b2876..9c428a2ab2 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -460,57 +460,99 @@ class ProgramDataExtender(object): def _attach_course_run_may_certify(self, run_mode): run_mode['may_certify'] = self.course_overview.may_certify() - def _check_enrollment_for_user(self, course_run): - applicable_seat_types = self.data['applicable_seat_types'] + def _filter_out_courses_with_entitlements(self, courses): + """ + Removes courses for which the current user already holds an applicable entitlement. - (enrollment_mode, active) = CourseEnrollment.enrollment_mode_for_user( - self.user, - CourseKey.from_string(course_run['key']) + TODO: + Add a NULL value of enrollment_course_run to filter, as courses with entitlements spent on applicable + enrollments will already have been filtered out by _filter_out_courses_with_enrollments. + + Arguments: + courses (list): Containing dicts representing courses in a program + + Returns: + A subset of the given list of course dicts + """ + course_uuids = set(course['uuid'] for course in courses) + # Filter the entitlements' modes with a case-insensitive match against applicable seat_types + entitlements = self.user.courseentitlement_set.filter( + mode__in=self.data['applicable_seat_types'], + course_uuid__in=course_uuids, ) + # Here we check the entitlements' expired_at_datetime property rather than filter by the expired_at attribute + # to ensure that the expiration status is as up to date as possible + entitlements = [e for e in entitlements if not e.expired_at_datetime] + courses_with_entitlements = set(unicode(entitlement.course_uuid) for entitlement in entitlements) + return [course for course in courses if course['uuid'] not in courses_with_entitlements] - is_paid_seat = False - if enrollment_mode is not None and active is not None and active is True: - # Check all the applicable seat types - # this will also check for no-id-professional as professional - is_paid_seat = any(seat_type in enrollment_mode for seat_type in applicable_seat_types) + def _filter_out_courses_with_enrollments(self, courses): + """ + Removes courses for which the current user already holds an active and applicable enrollment + for one of that course's runs. - return is_paid_seat + Arguments: + courses (list): Containing dicts representing courses in a program + + Returns: + A subset of the given list of course dicts + """ + enrollments = self.user.courseenrollment_set.filter( + is_active=True, + mode__in=self.data['applicable_seat_types'] + ) + course_runs_with_enrollments = set(unicode(enrollment.course_id) for enrollment in enrollments) + courses_without_enrollments = [] + for course in courses: + if all(unicode(run['key']) not in course_runs_with_enrollments for run in course['course_runs']): + courses_without_enrollments.append(course) + + return courses_without_enrollments def _collect_one_click_purchase_eligibility_data(self): """ Extend the program data with data about learner's eligibility for one click purchase, discount data of the program and SKUs of seats that should be added to basket. """ - applicable_seat_types = self.data['applicable_seat_types'] + if 'professional' in self.data['applicable_seat_types']: + self.data['applicable_seat_types'].append('no-id-professional') + applicable_seat_types = set(seat for seat in self.data['applicable_seat_types'] if seat != 'credit') + is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase'] skus = [] bundle_variant = 'full' + if is_learner_eligible_for_one_click_purchase: - for course in self.data['courses']: - add_course_sku = True - course_runs = course.get('course_runs', []) - published_course_runs = filter(lambda run: run['status'] == 'published', course_runs) + courses = self.data['courses'] + if not self.user.is_anonymous(): + courses = self._filter_out_courses_with_enrollments(courses) + courses = self._filter_out_courses_with_entitlements(courses) - if len(published_course_runs) == 1: - for course_run in course_runs: - is_paid_seat = self._check_enrollment_for_user(course_run) + if len(courses) < len(self.data['courses']): + bundle_variant = 'partial' - if is_paid_seat: - add_course_sku = False - break - - if add_course_sku: + for course in courses: + entitlement_product = False + for entitlement in course.get('entitlements', []): + # We add the first entitlement product found with an applicable seat type because, at this time, + # we are assuming that, for any given course, there is at most one paid entitlement available. + if entitlement['mode'] in applicable_seat_types: + skus.append(entitlement['sku']) + entitlement_product = True + break + if not entitlement_product: + course_runs = course.get('course_runs', []) + published_course_runs = [run for run in course_runs if run['status'] == 'published'] + if len(published_course_runs) == 1: for seat in published_course_runs[0]['seats']: if seat['type'] in applicable_seat_types and seat['sku']: skus.append(seat['sku']) + break else: - bundle_variant = 'partial' - else: - # If a course in the program has more than 1 published course run - # learner won't be eligible for a one click purchase. - is_learner_eligible_for_one_click_purchase = False - skus = [] - break + # If a course in the program has more than 1 published course run + # learner won't be eligible for a one click purchase. + skus = [] + break if skus: try: @@ -604,7 +646,7 @@ class ProgramMarketingDataExtender(ProgramDataExtender): def __init__(self, program_data, user): super(ProgramMarketingDataExtender, self).__init__(program_data, user) - # Aggregate list of instructors for the program + # Aggregate list of instructors for the program keyed by name self.instructors = [] # Values for programs' price calculation.