From bf164ae962c3e718bb3eb5a47a2e954daf762215 Mon Sep 17 00:00:00 2001 From: Marko Jevtic Date: Thu, 8 Jun 2017 11:56:11 +0000 Subject: [PATCH] [LEARNER-1183] Prepare program data to be presented on program marketing page [LEARNER-1393] Filter program course runs by status --- lms/djangoapps/courseware/views/views.py | 7 +- .../djangoapps/catalog/tests/factories.py | 62 ++++++- .../djangoapps/programs/tests/test_utils.py | 164 +++++++++++++++++- openedx/core/djangoapps/programs/utils.py | 73 +++++++- 4 files changed, 283 insertions(+), 23 deletions(-) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 4903b2b4f3..77d6217a78 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -801,8 +801,13 @@ def program_marketing(request, program_uuid): if not program_data: raise Http404 + program = ProgramMarketingDataExtender(program_data, request.user).extend() + skus = program.get('skus') + ecommerce_service = EcommerceService() + return render_to_response('courseware/program_marketing.html', { - 'program': ProgramMarketingDataExtender(program_data, request.user).extend() + 'buy_button_href': ecommerce_service.get_checkout_page_url(*skus) if skus else '#courses', + 'program': program, }) diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index 8343b5a15a..69c0906d38 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -3,6 +3,7 @@ from functools import partial import factory +import uuid from faker import Faker @@ -34,6 +35,19 @@ def generate_zulu_datetime(): return fake.date_time().isoformat() + 'Z' +def generate_price_ranges(): + return [{ + 'currency': 'USD', + 'max': 1000, + 'min': 100, + 'total': 500 + }] + + +def generate_seat_sku(): + return uuid.uuid4().hex[:7].upper() + + class DictFactoryBase(factory.Factory): """ Subclass this to make factories that can be used to produce fake API response @@ -77,12 +91,15 @@ class OrganizationFactory(DictFactoryBase): key = factory.Faker('word') name = factory.Faker('company') uuid = factory.Faker('uuid4') + logo_image_url = factory.Faker('image_url') class SeatFactory(DictFactoryBase): - type = factory.Faker('word') - price = factory.Faker('random_int') currency = 'USD' + price = factory.Faker('random_int') + sku = factory.LazyFunction(generate_seat_sku) + type = 'verified' + upgrade_deadline = factory.LazyFunction(generate_zulu_datetime) class CourseRunFactory(DictFactoryBase): @@ -91,13 +108,13 @@ class CourseRunFactory(DictFactoryBase): enrollment_end = factory.LazyFunction(generate_zulu_datetime) enrollment_start = factory.LazyFunction(generate_zulu_datetime) image = ImageFactory() - is_enrolled = False key = factory.LazyFunction(generate_course_run_key) marketing_url = factory.Faker('url') pacing_type = 'self_paced' seats = factory.LazyFunction(partial(generate_instances, SeatFactory)) short_description = factory.Faker('sentence') start = factory.LazyFunction(generate_zulu_datetime) + status = 'published' title = factory.Faker('catch_phrase') type = 'verified' uuid = factory.Faker('uuid4') @@ -112,20 +129,57 @@ class CourseFactory(DictFactoryBase): uuid = factory.Faker('uuid4') +class JobOutlookItemFactory(DictFactoryBase): + value = factory.Faker('sentence') + + +class PersonFactory(DictFactoryBase): + bio = factory.Faker('paragraphs') + given_name = factory.Faker('first_name') + family_name = factory.Faker('last_name') + profile_image_url = factory.Faker('image_url') + uuid = factory.Faker('uuid4') + + +class EndorserFactory(DictFactoryBase): + person = PersonFactory() + quote = factory.Faker('sentence') + + +class ExpectedLearningItemFactory(DictFactoryBase): + value = factory.Faker('sentence') + + +class FAQFactory(DictFactoryBase): + answer = factory.Faker('sentence') + question = factory.Faker('sentence') + + class ProgramFactory(DictFactoryBase): authoring_organizations = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1)) + applicable_seat_types = [] banner_image = factory.LazyFunction(generate_sized_stdimage) card_image_url = factory.Faker('image_url') courses = factory.LazyFunction(partial(generate_instances, CourseFactory)) + expected_learning_items = factory.LazyFunction(partial(generate_instances, CourseFactory)) + faq = factory.LazyFunction(partial(generate_instances, FAQFactory)) + hidden = False + individual_endorsements = factory.LazyFunction(partial(generate_instances, EndorserFactory)) is_program_eligible_for_one_click_purchase = True + job_outlook_items = factory.LazyFunction(partial(generate_instances, JobOutlookItemFactory)) marketing_slug = factory.Faker('slug') marketing_url = factory.Faker('url') + max_hours_effort_per_week = fake.random_int(21, 28) + min_hours_effort_per_week = fake.random_int(7, 14) + overview = factory.Faker('sentence') + price_ranges = factory.LazyFunction(generate_price_ranges) + staff = factory.LazyFunction(partial(generate_instances, PersonFactory)) status = 'active' subtitle = factory.Faker('sentence') title = factory.Faker('catch_phrase') type = factory.Faker('word') uuid = factory.Faker('uuid4') - hidden = False + weeks_to_complete = fake.random_int(1, 45) class ProgramTypeFactory(DictFactoryBase): diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 7adf47c351..f3cd99ed1a 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -1,9 +1,12 @@ """Tests covering Programs utilities.""" # pylint: disable=no-member import datetime +import json import uuid import ddt +import httpretty +from django.conf import settings from django.core.urlresolvers import reverse from django.test import TestCase from django.test.utils import override_settings @@ -14,6 +17,7 @@ from pytz import utc from course_modes.models import CourseMode 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 from openedx.core.djangoapps.catalog.tests.factories import ( generate_course_run_key, ProgramFactory, @@ -30,15 +34,15 @@ from openedx.core.djangoapps.programs.utils import ( get_certificates, ) from openedx.core.djangolib.testing.utils import skip_unless_lms -from student.tests.factories import UserFactory, CourseEnrollmentFactory +from student.tests.factories import AnonymousUserFactory, UserFactory, CourseEnrollmentFactory from util.date_utils import strftime_localized from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory as ModuleStoreCourseFactory -UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' CERTIFICATES_API_MODULE = 'lms.djangoapps.certificates.api' -ECOMMERCE_URL_ROOT = 'https://example-ecommerce.com' +ECOMMERCE_URL_ROOT = 'https://ecommerce.example.com' +UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' @ddt.ddt @@ -809,6 +813,7 @@ class TestGetCertificates(TestCase): @skip_unless_lms class TestProgramMarketingDataExtender(ModuleStoreTestCase): """Tests of the program data extender utility class.""" + ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT = '{root}/api/v2/baskets/calculate/'.format(root=ECOMMERCE_URL_ROOT) instructors = { 'instructors': [ { @@ -825,13 +830,16 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): def setUp(self): super(TestProgramMarketingDataExtender, self).setUp() + # Ensure the E-Commerce service user exists + UserFactory(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME, is_staff=True) + self.course_price = 100 self.number_of_courses = 2 self.program = ProgramFactory( courses=[self._create_course(self.course_price) for __ in range(self.number_of_courses)] ) - def _create_course(self, course_price, is_enrolled=False): + def _create_course(self, course_price): """ Creates the course in mongo and update it with the instructor data. Also creates catalog course with respect to course run. @@ -846,12 +854,24 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): course = self.update_course(course, self.user.id) course_run = CourseRunFactory( - is_enrolled=is_enrolled, key=unicode(course.id), seats=[SeatFactory(price=course_price)] ) return CourseFactory(course_runs=[course_run]) + def _prepare_program_for_discounted_price_calculation_endpoint(self): + """ + Program's applicable seat types should match some or all seat types of the seats that are a part of the program. + Otherwise, ecommerce API endpoint for calculating the discounted price won't be called. + + Returns: + seat: seat for which the discount is applicable + """ + self.ecommerce_service = EcommerceService() + seat = self.program['courses'][0]['course_runs'][0]['seats'][0] + self.program['applicable_seat_types'] = [seat['type']] + return seat + def test_instructors(self): data = ProgramMarketingDataExtender(self.program, self.user).extend() @@ -881,8 +901,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): def test_learner_eligibility_for_one_click_purchase(self): """ Learner should be eligible for one click purchase if: - - program is eligible for one click purchase - - learner is not enrolled in any of the course runs associated with the program + - program is eligible for one click purchase + - learner is not enrolled in any of the course runs associated with the program """ data = ProgramMarketingDataExtender(self.program, self.user).extend() self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) @@ -896,10 +916,136 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): data = ProgramMarketingDataExtender(program, self.user).extend() self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) - courses.append(self._create_course(self.course_price, is_enrolled=True)) + course = self._create_course(self.course_price) + CourseEnrollmentFactory(user=self.user, course_id=course['course_runs'][0]['key']) program2 = ProgramFactory( - courses=courses, + courses=[course], is_program_eligible_for_one_click_purchase=True ) data = ProgramMarketingDataExtender(program2, self.user).extend() self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) + + def test_multiple_published_course_runs(self): + """ + Learner should not be eligible for one click purchase if: + - program has a course with more than one published course run + """ + course_run_1 = CourseRunFactory( + key=str(ModuleStoreCourseFactory().id), + status='published' + ) + course_run_2 = CourseRunFactory( + key=str(ModuleStoreCourseFactory().id), + status='published' + ) + course = CourseFactory(course_runs=[course_run_1, course_run_2]) + program = ProgramFactory( + courses=[ + CourseFactory(course_runs=[ + CourseRunFactory( + key=str(ModuleStoreCourseFactory().id), + status='published' + ) + ]), + course, + CourseFactory(course_runs=[ + CourseRunFactory( + key=str(ModuleStoreCourseFactory().id), + status='published' + ) + ]) + ], + is_program_eligible_for_one_click_purchase=True + ) + data = ProgramMarketingDataExtender(program, self.user).extend() + + self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) + + course_run_2['status'] = 'unpublished' + data = ProgramMarketingDataExtender(program, self.user).extend() + + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) + + @httpretty.activate + def test_fetching_program_discounted_price(self): + """ + Authenticated users eligible for one click purchase should see the purchase button + - displaying program's discounted price if it exists. + - leading to ecommerce basket page + """ + self._prepare_program_for_discounted_price_calculation_endpoint() + mock_discount_data = { + 'total_incl_tax_excl_discounts': 200.0, + 'currency': "USD", + 'total_incl_tax': 50.0 + } + httpretty.register_uri( + httpretty.GET, + self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, + body=json.dumps(mock_discount_data), + content_type='application/json' + ) + + data = ProgramMarketingDataExtender(self.program, self.user).extend() + + self.assertEqual( + data['skus'], + [course['course_runs'][0]['seats'][0]['sku'] for course in self.program['courses']] + ) + self.assertEqual(data['discount_data'], mock_discount_data) + + @httpretty.activate + def test_fetching_program_discounted_price_as_anonymous_user(self): + """ + Anonymous users should see the purchase button same way the authenticated users do + when the program is eligible for one click purchase. + """ + self._prepare_program_for_discounted_price_calculation_endpoint() + mock_discount_data = { + 'total_incl_tax_excl_discounts': 200.0, + 'currency': "USD", + 'total_incl_tax': 50.0 + } + httpretty.register_uri( + httpretty.GET, + self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, + body=json.dumps(mock_discount_data), + content_type='application/json' + ) + + data = ProgramMarketingDataExtender(self.program, AnonymousUserFactory()).extend() + + self.assertEqual( + data['skus'], + [course['course_runs'][0]['seats'][0]['sku'] for course in self.program['courses']] + ) + self.assertEqual(data['discount_data'], mock_discount_data) + + def test_fetching_program_discounted_price_no_applicable_seats(self): + """ + User shouldn't be able to do a one click purchase of a program if a program has no applicable seat types. + """ + data = ProgramMarketingDataExtender(self.program, self.user).extend() + + self.assertEqual(len(data['skus']), 0) + + @httpretty.activate + def test_fetching_program_discounted_price_api_exception_caught(self): + """ + User should be able to do a one click purchase of a program even if the ecommerce API throws an exception + during the calculation of program discounted price. + """ + self._prepare_program_for_discounted_price_calculation_endpoint() + httpretty.register_uri( + httpretty.GET, + self.ECOMMERCE_CALCULATE_DISCOUNT_ENDPOINT, + status=400, + content_type='application/json' + ) + + data = ProgramMarketingDataExtender(self.program, self.user).extend() + + self.assertEqual( + data['skus'], + [course['course_runs'][0]['seats'][0]['sku'] for course in self.program['courses']] + ) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index dbc7421cd8..f962141aae 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Helper functions for working with Programs.""" import datetime +import logging from collections import defaultdict from copy import deepcopy from itertools import chain @@ -8,17 +9,21 @@ from urlparse import urljoin from dateutil.parser import parse from django.conf import settings +from django.contrib.auth import get_user_model from django.core.cache import cache from django.core.urlresolvers import reverse from django.utils.functional import cached_property +from edx_rest_api_client.exceptions import SlumberBaseException from opaque_keys.edx.keys import CourseKey from pytz import utc +from requests.exceptions import ConnectionError, Timeout from course_modes.models import CourseMode from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.courseware.access import has_access from openedx.core.djangoapps.catalog.utils import get_programs +from openedx.core.djangoapps.commerce.utils import ecommerce_api_client from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credentials.utils import get_credentials from student.models import CourseEnrollment @@ -28,6 +33,8 @@ from xmodule.modulestore.django import modulestore # The datetime module's strftime() methods require a year >= 1900. DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc) +log = logging.getLogger(__name__) + def get_program_marketing_url(programs_config): """Build a URL used to link to programs on the marketing site.""" @@ -507,27 +514,25 @@ class ProgramMarketingDataExtender(ProgramDataExtender): uuid=self.data['uuid'] ) program_instructors = cache.get(cache_key) - is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase'] for course in self.data['courses']: self._execute('_collect_course', course) if not program_instructors: for course_run in course['course_runs']: self._execute('_collect_instructors', course_run) - if is_learner_eligible_for_one_click_purchase: - is_learner_eligible_for_one_click_purchase = not any( - course_run['is_enrolled'] for course_run in course['course_runs'] - ) if not program_instructors: # We cache the program instructors list to avoid repeated modulestore queries program_instructors = self.instructors.values() cache.set(cache_key, program_instructors, 3600) - self.data.update({ - 'instructors': program_instructors, - 'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase, - }) + self.data['instructors'] = program_instructors + + def extend(self): + """Execute extension handlers, returning the extended data.""" + self.data.update(super(ProgramMarketingDataExtender, self).extend()) + self._collect_one_click_purchase_eligibility_data() + return self.data @classmethod def _handlers(cls, prefix): @@ -582,3 +587,53 @@ class ProgramMarketingDataExtender(ProgramDataExtender): self.instructors.update( {instructor.get('name'): instructor for instructor in course_instructors.get('instructors', [])} ) + + 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'] + is_learner_eligible_for_one_click_purchase = self.data['is_program_eligible_for_one_click_purchase'] + skus = [] + if is_learner_eligible_for_one_click_purchase: + for course in self.data['courses']: + is_learner_eligible_for_one_click_purchase = not any( + course_run['is_enrolled'] for course_run in course['course_runs'] + ) + if is_learner_eligible_for_one_click_purchase: + published_course_runs = filter(lambda run: run['status'] == 'published', course['course_runs']) + if len(published_course_runs) == 1: + for seat in published_course_runs[0]['seats']: + if seat['type'] in applicable_seat_types: + skus.append(seat['sku']) + 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 + else: + skus = [] + break + + if skus: + try: + User = get_user_model() + service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME) + api = ecommerce_api_client(service_user) + + # Make an API call to calculate the discounted price + discount_data = api.baskets.calculate.get(sku=skus) + + self.data.update({ + 'discount_data': discount_data, + 'full_program_price': discount_data['total_incl_tax'] + }) + except (ConnectionError, SlumberBaseException, Timeout): + log.exception('Failed to get discount price for following product SKUs: %s ', ', '.join(skus)) + + self.data.update({ + 'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase, + 'skus': skus, + })