diff --git a/lms/djangoapps/experiments/tests/test_utils.py b/lms/djangoapps/experiments/tests/test_utils.py index 83ee0d39e63125c225405490b9ca776bb9f57849..62eb8573a5704aca6dd85ffeaa312efdb717f46c 100644 --- a/lms/djangoapps/experiments/tests/test_utils.py +++ b/lms/djangoapps/experiments/tests/test_utils.py @@ -1,8 +1,10 @@ """ Tests of experiment functionality """ +from decimal import Decimal from unittest import TestCase -from lms.djangoapps.experiments.utils import is_enrolled_in_course_run +from lms.djangoapps.experiments.utils import get_course_entitlement_price_and_sku, get_program_price_and_skus, \ + get_program_purchase_url, get_unenrolled_courses, is_enrolled_in_course_run from opaque_keys.edx.keys import CourseKey @@ -10,6 +12,22 @@ class ExperimentUtilsTests(TestCase): """ Tests of experiment functionality """ + + def setUp(self): + super(ExperimentUtilsTests, self).setUp() + + # Create a course run + self.run_a_price = '86.00' + self.run_a_sku = 'B9B6D0B' + seat_a = {'type': 'verified', 'price': self.run_a_price, 'sku': self.run_a_sku} + seats = [seat_a] + self.course_run_a = {'status': 'published', 'seats': seats} + + # Create an entitlement + self.entitlement_a_price = '199.23' + self.entitlement_a_sku = 'B37EBA0' + self.entitlement_a = {'mode': 'verified', 'price': self.entitlement_a_price, 'sku': self.entitlement_a_sku} + def test_valid_course_run_key_enrollment(self): course_run = { 'key': 'course-v1:DelftX+NGIx+RA0', @@ -23,3 +41,104 @@ class ExperimentUtilsTests(TestCase): } enrollment_ids = {CourseKey.from_string('course-v1:DelftX+NGIx+RA0')} self.assertFalse(is_enrolled_in_course_run(course_run, enrollment_ids)) + + def test_program_url_with_no_skus(self): + url = get_program_purchase_url(None) + self.assertEqual(None, url) + + def test_program_url_with_no_skus_and_no_bundle(self): + url = get_program_purchase_url(None, None) + self.assertEqual(None, url) + + def test_program_url_with_single_sku(self): + skus = ['9FE0DE2'] + expected_url = 'https://ecommerce.edx.org/basket/add/?sku=9FE0DE2' + url = get_program_purchase_url(skus) + self.assertEqual(expected_url, url) + + def test_program_url_with_single_sku_and_bundle(self): + skus = ['9FE0DE2'] + program_id = 'bef7201a-6f97-40ad-ad17-d5ea8be1eec8' + expected_url = 'https://ecommerce.edx.org/basket/add/?sku=9FE0DE2&bundle=' + program_id + url = get_program_purchase_url(skus, program_id) + self.assertEqual(expected_url, url) + + def test_program_url_with_multiple_skus(self): + skus = ['9FE0DE2', 'B37EBA0', 'FDCED11'] + expected_url = 'https://ecommerce.edx.org/basket/add/?sku=9FE0DE2&sku=B37EBA0&sku=FDCED11' + url = get_program_purchase_url(skus) + self.assertEqual(expected_url, url) + + def test_program_url_with_multiple_skus_and_bundle(self): + skus = ['9FE0DE2', 'B37EBA0', 'FDCED11'] + program_id = 'bef7201a-6f97-40ad-ad17-d5ea8be1eec8' + expected_url = 'https://ecommerce.edx.org/basket/add/?sku=9FE0DE2&sku=B37EBA0&sku=FDCED11&bundle=' + program_id + url = get_program_purchase_url(skus, program_id) + self.assertEqual(expected_url, url) + + def test_program_price_and_skus_for_empty_courses(self): + price, skus = get_program_price_and_skus([]) + self.assertEqual(None, price) + self.assertEqual(None, skus) + + def test_unenrolled_courses_for_empty_courses(self): + unenrolled_courses = get_unenrolled_courses([], []) + self.assertEqual([], unenrolled_courses) + + def test_unenrolled_courses_for_single_course(self): + course = {'key': 'UQx+ENGY1x'} + courses_in_program = [course] + user_enrollments = [] + + unenrolled_courses = get_unenrolled_courses(courses_in_program, user_enrollments) + expected_unenrolled_courses = [course] + self.assertEqual(expected_unenrolled_courses, unenrolled_courses) + + def test_price_and_sku_from_empty_course(self): + course = {} + + price, sku = get_course_entitlement_price_and_sku(course) + self.assertEqual(None, price) + self.assertEqual(None, sku) + + def test_price_and_sku_from_entitlement(self): + entitlements = [self.entitlement_a] + course = {'key': 'UQx+ENGY1x', 'entitlements': entitlements} + + price, sku = get_course_entitlement_price_and_sku(course) + self.assertEqual(self.entitlement_a_price, price) + self.assertEqual(self.entitlement_a_sku, sku) + + def test_price_and_sku_from_course_run(self): + course_runs = [self.course_run_a] + course = {'key': 'UQx+ENGY1x', 'course_runs': course_runs} + + price, sku = get_course_entitlement_price_and_sku(course) + expected_price = Decimal(self.run_a_price) + self.assertEqual(expected_price, price) + self.assertEqual(self.run_a_sku, sku) + + def test_price_and_sku_from_course(self): + entitlements = [self.entitlement_a] + course_a = {'key': 'UQx+ENGYCAPx', 'entitlements': entitlements} + courses = [course_a] + + price, skus = get_program_price_and_skus(courses) + expected_price = u'$199.23' + self.assertEqual(expected_price, price) + self.assertEqual(1, len(skus)) + self.assertIn(self.entitlement_a_sku, skus) + + def test_price_and_sku_from_multiple_courses(self): + entitlements = [self.entitlement_a] + course_runs = [self.course_run_a] + course_a = {'key': 'UQx+ENGY1x', 'course_runs': course_runs} + course_b = {'key': 'UQx+ENGYCAPx', 'entitlements': entitlements} + courses = [course_a, course_b] + + price, skus = get_program_price_and_skus(courses) + expected_price = u'$285.23' + self.assertEqual(expected_price, price) + self.assertEqual(2, len(skus)) + self.assertIn(self.run_a_sku, skus) + self.assertIn(self.entitlement_a_sku, skus) diff --git a/lms/djangoapps/experiments/utils.py b/lms/djangoapps/experiments/utils.py index 7bb349dff5cce22833fb9332c01807f988d9026c..bd83d6b240762dc5a398058cb89c5cdcd7cb9f1b 100644 --- a/lms/djangoapps/experiments/utils.py +++ b/lms/djangoapps/experiments/utils.py @@ -5,9 +5,11 @@ Utilities to facilitate experimentation import hashlib import re import logging +from decimal import Decimal from student.models import CourseEnrollment from django_comment_common.models import Role -from course_modes.models import get_cosmetic_verified_display_price +from django.utils.timezone import now +from course_modes.models import get_cosmetic_verified_display_price, format_course_price from courseware.access import has_staff_access_to_preview_mode from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid from xmodule.partitions.partitions_service import get_user_partition_groups, get_all_partitions_for_course @@ -37,6 +39,24 @@ PROGRAM_INFO_FLAG = WaffleFlag( flag_name=u'add_programs', flag_undefined_default=False ) + +# .. feature_toggle_name: experiments.add_program_price +# .. feature_toggle_type: flag +# .. feature_toggle_default: False +# .. feature_toggle_description: Toggle for adding the current course's program price and sku information to user +# metadata +# .. feature_toggle_category: experiments +# .. feature_toggle_use_cases: monitored_rollout +# .. feature_toggle_creation_date: 2019-3-12 +# .. feature_toggle_expiration_date: None +# .. feature_toggle_warnings: None +# .. feature_toggle_tickets: REVEM-118, REVEM-206 +# .. feature_toggle_status: supported +PROGRAM_PRICE_FLAG = WaffleFlag( + waffle_namespace=WaffleFlagNamespace(name=u'experiments'), + flag_name=u'add_program_price', + flag_undefined_default=False +) # TODO: clean up as part of REVEM-199 (END) @@ -78,14 +98,96 @@ def check_and_get_upgrade_link_and_date(user, enrollment=None, course=None): # TODO: clean up as part of REVEM-199 (START) -def is_enrolled_in_all_courses_in_program(courses_in_program, user_enrollments): +def get_program_purchase_url(skus, bundle_id=None): + """ + Return a url that will allow the purchase of the courses with these skus + """ + if not skus: + return None + + url = 'https://ecommerce.edx.org/basket/add/?' + for sku in skus: + url += 'sku=' + sku + '&' + + if bundle_id: + url += 'bundle=' + bundle_id + else: + # Remove trailing & from the skus + url = url[:-1] + return url + + +def get_program_price_and_skus(courses): + """ + Get the total program price and purchase skus from these courses in the program + """ + program_price = 0 + skus = [] + + for course in courses: + course_price, course_sku = get_course_entitlement_price_and_sku(course) + if course_price is not None and course_sku is not None: + program_price = Decimal(program_price) + Decimal(course_price) + skus.append(course_sku) + + if program_price <= 0: + program_price = None + skus = None + else: + program_price = format_course_price(program_price) + program_price = unicode(program_price) + + return program_price, skus + + +def get_course_entitlement_price_and_sku(course): + """ + Get the entitlement price and sku from this course. + Try to get them from the first non-expired, verified entitlement that has a price and a sku. If that doesn't work, + fall back to the first non-expired, verified course run that has a price and a sku. + """ + for entitlement in course.get('entitlements', []): + if entitlement.get('mode') == 'verified' and entitlement['price'] and entitlement['sku']: + expires = entitlement.get('expires') + if not expires or expires > now(): + return entitlement['price'], entitlement['sku'] + + course_runs = course.get('course_runs', []) + published_course_runs = [run for run in course_runs if run['status'] == 'published'] + for published_course_run in published_course_runs: + for seat in published_course_run['seats']: + if seat.get('type') == 'verified' and seat['price'] and seat['sku']: + price = Decimal(seat.get('price')) + return price, seat.get('sku') + + return None, None + + +def get_unenrolled_courses(courses, user_enrollments): + """ + Given a list of courses and a list of user enrollments, return the courses in which the user is not enrolled. + Depending on the enrollments that are passed in, this method can be used to determine the courses in a program in + which the user has not yet enrolled or the courses in a program for which the user has not yet purchased a + certificate. + """ + # Get the enrollment course ids here, so we don't need to loop through them for every course run + enrollment_course_ids = {enrollment.course_id for enrollment in user_enrollments} + unenrolled_courses = [] + + for course in courses: + if not is_enrolled_in_course(course, enrollment_course_ids): + unenrolled_courses.append(course) + return unenrolled_courses + + +def is_enrolled_in_all_courses(courses, user_enrollments): """ - Determine if the user is enrolled in all courses in this program + Determine if the user is enrolled in all of the courses """ # Get the enrollment course ids here, so we don't need to loop through them for every course run enrollment_course_ids = {enrollment.course_id for enrollment in user_enrollments} - for course in courses_in_program: + for course in courses: if not is_enrolled_in_course(course, enrollment_course_ids): # User is not enrolled in this course, meaning they are not enrolled in all courses in the program return False @@ -148,18 +250,40 @@ def get_experiment_user_metadata_context(course, user): # A course can be in multiple programs, but we're just grabbing the first one program = programs[0] complete_enrollment = False + has_courses_left_to_purchase = False total_courses = None courses = program.get('courses') + courses_left_to_purchase_price = None + courses_left_to_purchase_url = None + program_uuid = program.get('uuid') if courses is not None: total_courses = len(courses) - complete_enrollment = is_enrolled_in_all_courses_in_program(courses, user_enrollments) + complete_enrollment = is_enrolled_in_all_courses(courses, user_enrollments) + + if PROGRAM_PRICE_FLAG.is_enabled(): + # Get the price and purchase URL of the program courses the user has yet to purchase. Say a + # program has 3 courses (A, B and C), and the user previously purchased a certificate for A. + # The user is enrolled in audit mode for B. The "left to purchase price" should be the price of + # B+C. + non_audit_enrollments = [enrollment for enrollment in user_enrollments if enrollment not in + audit_enrollments] + courses_left_to_purchase = get_unenrolled_courses(courses, non_audit_enrollments) + if courses_left_to_purchase: + has_courses_left_to_purchase = True + courses_left_to_purchase_price, courses_left_to_purchase_skus = get_program_price_and_skus( + courses_left_to_purchase) + courses_left_to_purchase_url = get_program_purchase_url(courses_left_to_purchase_skus, + program_uuid) program_key = { - 'uuid': program.get('uuid'), + 'uuid': program_uuid, 'title': program.get('title'), 'marketing_url': program.get('marketing_url'), 'total_courses': total_courses, 'complete_enrollment': complete_enrollment, + 'has_courses_left_to_purchase': has_courses_left_to_purchase, + 'courses_left_to_purchase_price': courses_left_to_purchase_price, + 'courses_left_to_purchase_url': courses_left_to_purchase_url, } # TODO: clean up as part of REVEM-199 (END) enrollment = CourseEnrollment.objects.select_related(