From de6d48a698358677e17df715ab311d2fb2e30a5f Mon Sep 17 00:00:00 2001 From: "Albert St. Aubin" Date: Fri, 28 Jul 2017 09:47:33 -0400 Subject: [PATCH] Added Program Purchase button to the Programs dashboard Learners can upgrade or enroll as verified in all remaining courses in a program from their programs dashboard [LEARNER-1899] --- lms/djangoapps/learner_dashboard/views.py | 12 +++-- .../views/program_details_view.js | 4 +- .../program_details_view_spec.js | 48 +++++++++++++++++- lms/static/sass/views/_program-details.scss | 50 ++++++++++++------- .../program_details_view.underscore | 17 +++++++ .../upgrade_message.underscore | 4 +- .../djangoapps/programs/tests/test_utils.py | 22 +++++--- openedx/core/djangoapps/programs/utils.py | 30 +++++++---- 8 files changed, 142 insertions(+), 45 deletions(-) diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py index e166ee1ea0..57202af5c4 100644 --- a/lms/djangoapps/learner_dashboard/views.py +++ b/lms/djangoapps/learner_dashboard/views.py @@ -5,11 +5,12 @@ from django.http import Http404 from django.views.decorators.http import require_GET from edxmako.shortcuts import render_to_response +from commerce.utils import EcommerceService + from lms.djangoapps.learner_dashboard.utils import FAKE_COURSE_KEY, strip_course_id -from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.utils import ( - ProgramDataExtender, + ProgramMarketingDataExtender, ProgramProgressMeter, get_certificates, get_program_marketing_url @@ -54,11 +55,13 @@ def program_details(request, program_uuid): if not program_data: raise Http404 - program_data = ProgramDataExtender(program_data, request.user).extend() + program_data = ProgramMarketingDataExtender(program_data, request.user).extend() course_data = meter.progress(programs=[program_data], count_only=False)[0] certificate_data = get_certificates(request.user, program_data) program_data.pop('courses') + skus = program_data.get('skus') + ecommerce_service = EcommerceService() urls = { 'program_listing_url': reverse('program_listing_view'), @@ -66,6 +69,7 @@ def program_details(request, program_uuid): reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY}) ), 'commerce_api_url': reverse('commerce_api:v0:baskets:create'), + 'buy_button_url': ecommerce_service.get_checkout_page_url(*skus) } context = { @@ -77,7 +81,7 @@ def program_details(request, program_uuid): 'user_preferences': get_user_preferences(request.user), 'program_data': program_data, 'course_data': course_data, - 'certificate_data': certificate_data, + 'certificate_data': certificate_data } return render_to_response('learner_dashboard/program_details.html', context) diff --git a/lms/static/js/learner_dashboard/views/program_details_view.js b/lms/static/js/learner_dashboard/views/program_details_view.js index 442a98a6b8..435e3966c3 100644 --- a/lms/static/js/learner_dashboard/views/program_details_view.js +++ b/lms/static/js/learner_dashboard/views/program_details_view.js @@ -32,6 +32,7 @@ initialize: function(options) { this.options = options; + this.programModel = new Backbone.Model(this.options.programData); this.courseData = new Backbone.Model(this.options.courseData); this.certificateCollection = new Backbone.Collection(this.options.certificateData); @@ -60,7 +61,8 @@ totalCount: totalCount, inProgressCount: inProgressCount, remainingCount: remainingCount, - completedCount: completedCount + completedCount: completedCount, + completeProgramURL: this.options.urls.buy_button_url }; data = $.extend(data, this.programModel.toJSON()); HtmlUtils.setHtml(this.$el, this.tpl(data)); diff --git a/lms/static/js/spec/learner_dashboard/program_details_view_spec.js b/lms/static/js/spec/learner_dashboard/program_details_view_spec.js index cde77f6144..ddb4d5ac1a 100644 --- a/lms/static/js/spec/learner_dashboard/program_details_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/program_details_view_spec.js @@ -52,6 +52,14 @@ define([ marketing_url: 'someurl', status: 'active', credit_redemption_overview: '', + discount_data: { + currency: 'USD', + discount_value: 0, + is_discounted: false, + total_incl_tax: 300, + total_incl_tax_excl_discounts: 300 + }, + full_program_price: 300, card_image_url: 'some image', faq: [], price_ranges: [ @@ -117,7 +125,8 @@ define([ credit_backing_organizations: [], weeks_to_complete_min: 8, weeks_to_complete_max: 8, - min_hours_effort_per_week: null + min_hours_effort_per_week: null, + is_learner_eligible_for_one_click_purchase: false }, courseData: { completed: [ @@ -549,7 +558,42 @@ define([ view.render(); expect($(view.$('.upgrade-message .card-msg')).text().trim()).toEqual('Certificate Status:'); expect($(view.$('.upgrade-message .price')).text().trim()).toEqual('$10.00'); - expect($(view.$('.upgrade-button')[0]).text().trim()).toEqual('Buy Certificate'); + expect($(view.$('.upgrade-button.single-course-run')[0]).text().trim()).toEqual('Upgrade to Verified'); + }); + + it('should render full program purchase link', function() { + view = initView({ + programData: $.extend({}, options.programData, { + is_learner_eligible_for_one_click_purchase: true + }) + }); + view.render(); + expect($(view.$('.upgrade-button.complete-program')).text().trim(). + replace(/\s+/g, ' ')). + toEqual( + 'Upgrade All Remaining Courses ( $300 USD )' + ); + }); + + it('should render partial program purchase link', function() { + view = initView({ + programData: $.extend({}, options.programData, { + is_learner_eligible_for_one_click_purchase: true, + discount_data: { + currency: 'USD', + discount_value: 30, + is_discounted: true, + total_incl_tax: 300, + total_incl_tax_excl_discounts: 270 + } + }) + }); + view.render(); + expect($(view.$('.upgrade-button.complete-program')).text().trim(). + replace(/\s+/g, ' ')). + toEqual( + 'Upgrade All Remaining Courses ( $270 $300 USD )' + ); }); it('should render enrollment information', function() { diff --git a/lms/static/sass/views/_program-details.scss b/lms/static/sass/views/_program-details.scss index 8eb9b60fa7..725373212c 100644 --- a/lms/static/sass/views/_program-details.scss +++ b/lms/static/sass/views/_program-details.scss @@ -285,8 +285,10 @@ } .program-heading { - width: 100%; margin-bottom: 40px; + display: flex; + justify-content: flex-start; + flex-direction: column; .program-heading-title { font-family: "Open Sans"; @@ -300,6 +302,7 @@ .program-heading-message { font-weight: 300; } + } .course-enroll-view { @@ -387,7 +390,34 @@ padding: 0; } } - + + .upgrade-button { + background: palette(success, text); + border-color: palette(success, text); + border-radius: 0; + padding: 7px; + text-align: center; + font-size: 0.9375em; + + /* IE11 CSS styles */ + @media(min-width: $bp-screen-md) and (-ms-high-contrast: none), (-ms-high-contrast: active) { + @include float(right); + } + &.complete-program { + margin: 10px 15px 10px 5px; + align-self: flex-start; + + @media(min-width: $bp-screen-md) { + align-self: flex-end; + } + + .list-price { + text-decoration: line-through; + } + } + } + + .program-course-card { width: 100%; padding: 15px; @@ -462,22 +492,6 @@ .upgrade-message { flex-wrap: wrap; - .upgrade-button { - background: palette(success, text); - border-color: palette(success, text); - height: 37px; - width: 128px; - border-radius: 0; - padding: 7px 0 0 0; - text-align: center; - font-size: 0.9375em; - - /* IE11 CSS styles */ - @media(min-width: $bp-screen-md) and (-ms-high-contrast: none), (-ms-high-contrast: active) { - @include float(right); - } - } - .action { width: 100%; margin: 5px 0; diff --git a/lms/templates/learner_dashboard/program_details_view.underscore b/lms/templates/learner_dashboard/program_details_view.underscore index ba314359b7..a6c4f69eb1 100644 --- a/lms/templates/learner_dashboard/program_details_view.underscore +++ b/lms/templates/learner_dashboard/program_details_view.underscore @@ -20,6 +20,23 @@
<%- gettext('To complete the program, you must earn a verified certificate for each course.') %>
<% } %> + <% if (is_learner_eligible_for_one_click_purchase) { %> + + <%- gettext('Upgrade All Remaining Courses (')%> + <% if (discount_data.is_discounted) { %> + + <%- StringUtils.interpolate( + gettext('${listPrice}'), {listPrice: discount_data.total_incl_tax_excl_discounts} + ) + %> + + <% } %> + <%- StringUtils.interpolate( + gettext(' ${price} {currency} )'), {price: full_program_price, currency: discount_data.currency} + ) + %> + + <% } %>
<% if (inProgressCount) { %> diff --git a/lms/templates/learner_dashboard/upgrade_message.underscore b/lms/templates/learner_dashboard/upgrade_message.underscore index 603e6c8908..6d79a51238 100644 --- a/lms/templates/learner_dashboard/upgrade_message.underscore +++ b/lms/templates/learner_dashboard/upgrade_message.underscore @@ -4,7 +4,7 @@ <%- price %>
- - <%- gettext('Buy Certificate') %> + + <%- gettext('Upgrade to Verified') %>
diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 582d051024..cddc06aedf 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -846,7 +846,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): 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)] + courses=[self._create_course(self.course_price) for __ in range(self.number_of_courses)], + applicable_seat_types=['verified'] ) def _create_course(self, course_price): @@ -940,7 +941,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): """ 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 + - There are courses remaining that have not been purchased and enrolled in. """ data = ProgramMarketingDataExtender(self.program, self.user).extend() self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) @@ -954,14 +955,17 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): data = ProgramMarketingDataExtender(program, self.user).extend() self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) - course = self._create_course(self.course_price) - CourseEnrollmentFactory(user=self.user, course_id=course['course_runs'][0]['key']) + course1 = self._create_course(self.course_price) + course2 = self._create_course(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') program2 = ProgramFactory( - courses=[course], - is_program_eligible_for_one_click_purchase=True + courses=[course1, course2], + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=['verified'], ) data = ProgramMarketingDataExtender(program2, self.user).extend() - self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) + self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) def test_multiple_published_course_runs(self): """ @@ -993,7 +997,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ) ]) ], - is_program_eligible_for_one_click_purchase=True + is_program_eligible_for_one_click_purchase=True, + applicable_seat_types=['verified'] ) data = ProgramMarketingDataExtender(program, self.user).extend() @@ -1065,6 +1070,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): """ User shouldn't be able to do a one click purchase of a program if a program has no applicable seat types. """ + self.program['applicable_seat_types'] = [] data = ProgramMarketingDataExtender(self.program, self.user).extend() self.assertEqual(len(data['skus']), 0) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 7619d2c4d1..51dfd11100 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -600,10 +600,17 @@ class ProgramMarketingDataExtender(ProgramDataExtender): 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: + add_course_sku = False + for course_run in course['course_runs']: + (enrollment_mode, active) = CourseEnrollment.enrollment_mode_for_user( + self.user, + CourseKey.from_string(course_run['key']) + ) + if enrollment_mode not in applicable_seat_types or not active: + add_course_sku = True + break + + if add_course_sku: 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']: @@ -615,15 +622,16 @@ class ProgramMarketingDataExtender(ProgramDataExtender): 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) + api_user = self.user + if not self.user.is_authenticated(): + user = get_user_model() + service_user = user.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME) + api_user = service_user + + api = ecommerce_api_client(api_user) # Make an API call to calculate the discounted price discount_data = api.baskets.calculate.get(sku=skus) @@ -639,6 +647,8 @@ class ProgramMarketingDataExtender(ProgramDataExtender): }) except (ConnectionError, SlumberBaseException, Timeout): log.exception('Failed to get discount price for following product SKUs: %s ', ', '.join(skus)) + else: + is_learner_eligible_for_one_click_purchase = False self.data.update({ 'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase,