Commit de6d48a6 by Albert St. Aubin Committed by McKenzie Welter

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]
parent 679bd2c6
...@@ -5,11 +5,12 @@ from django.http import Http404 ...@@ -5,11 +5,12 @@ from django.http import Http404
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from edxmako.shortcuts import render_to_response 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 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.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.utils import ( from openedx.core.djangoapps.programs.utils import (
ProgramDataExtender, ProgramMarketingDataExtender,
ProgramProgressMeter, ProgramProgressMeter,
get_certificates, get_certificates,
get_program_marketing_url get_program_marketing_url
...@@ -54,11 +55,13 @@ def program_details(request, program_uuid): ...@@ -54,11 +55,13 @@ def program_details(request, program_uuid):
if not program_data: if not program_data:
raise Http404 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] course_data = meter.progress(programs=[program_data], count_only=False)[0]
certificate_data = get_certificates(request.user, program_data) certificate_data = get_certificates(request.user, program_data)
program_data.pop('courses') program_data.pop('courses')
skus = program_data.get('skus')
ecommerce_service = EcommerceService()
urls = { urls = {
'program_listing_url': reverse('program_listing_view'), 'program_listing_url': reverse('program_listing_view'),
...@@ -66,6 +69,7 @@ def program_details(request, program_uuid): ...@@ -66,6 +69,7 @@ def program_details(request, program_uuid):
reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY}) reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY})
), ),
'commerce_api_url': reverse('commerce_api:v0:baskets:create'), 'commerce_api_url': reverse('commerce_api:v0:baskets:create'),
'buy_button_url': ecommerce_service.get_checkout_page_url(*skus)
} }
context = { context = {
...@@ -77,7 +81,7 @@ def program_details(request, program_uuid): ...@@ -77,7 +81,7 @@ def program_details(request, program_uuid):
'user_preferences': get_user_preferences(request.user), 'user_preferences': get_user_preferences(request.user),
'program_data': program_data, 'program_data': program_data,
'course_data': course_data, 'course_data': course_data,
'certificate_data': certificate_data, 'certificate_data': certificate_data
} }
return render_to_response('learner_dashboard/program_details.html', context) return render_to_response('learner_dashboard/program_details.html', context)
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
initialize: function(options) { initialize: function(options) {
this.options = options; this.options = options;
this.programModel = new Backbone.Model(this.options.programData); this.programModel = new Backbone.Model(this.options.programData);
this.courseData = new Backbone.Model(this.options.courseData); this.courseData = new Backbone.Model(this.options.courseData);
this.certificateCollection = new Backbone.Collection(this.options.certificateData); this.certificateCollection = new Backbone.Collection(this.options.certificateData);
...@@ -60,7 +61,8 @@ ...@@ -60,7 +61,8 @@
totalCount: totalCount, totalCount: totalCount,
inProgressCount: inProgressCount, inProgressCount: inProgressCount,
remainingCount: remainingCount, remainingCount: remainingCount,
completedCount: completedCount completedCount: completedCount,
completeProgramURL: this.options.urls.buy_button_url
}; };
data = $.extend(data, this.programModel.toJSON()); data = $.extend(data, this.programModel.toJSON());
HtmlUtils.setHtml(this.$el, this.tpl(data)); HtmlUtils.setHtml(this.$el, this.tpl(data));
......
...@@ -52,6 +52,14 @@ define([ ...@@ -52,6 +52,14 @@ define([
marketing_url: 'someurl', marketing_url: 'someurl',
status: 'active', status: 'active',
credit_redemption_overview: '', 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', card_image_url: 'some image',
faq: [], faq: [],
price_ranges: [ price_ranges: [
...@@ -117,7 +125,8 @@ define([ ...@@ -117,7 +125,8 @@ define([
credit_backing_organizations: [], credit_backing_organizations: [],
weeks_to_complete_min: 8, weeks_to_complete_min: 8,
weeks_to_complete_max: 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: { courseData: {
completed: [ completed: [
...@@ -549,7 +558,42 @@ define([ ...@@ -549,7 +558,42 @@ define([
view.render(); view.render();
expect($(view.$('.upgrade-message .card-msg')).text().trim()).toEqual('Certificate Status:'); expect($(view.$('.upgrade-message .card-msg')).text().trim()).toEqual('Certificate Status:');
expect($(view.$('.upgrade-message .price')).text().trim()).toEqual('$10.00'); 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() { it('should render enrollment information', function() {
......
...@@ -285,8 +285,10 @@ ...@@ -285,8 +285,10 @@
} }
.program-heading { .program-heading {
width: 100%;
margin-bottom: 40px; margin-bottom: 40px;
display: flex;
justify-content: flex-start;
flex-direction: column;
.program-heading-title { .program-heading-title {
font-family: "Open Sans"; font-family: "Open Sans";
...@@ -300,6 +302,7 @@ ...@@ -300,6 +302,7 @@
.program-heading-message { .program-heading-message {
font-weight: 300; font-weight: 300;
} }
} }
.course-enroll-view { .course-enroll-view {
...@@ -388,6 +391,33 @@ ...@@ -388,6 +391,33 @@
} }
} }
.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 { .program-course-card {
width: 100%; width: 100%;
padding: 15px; padding: 15px;
...@@ -462,22 +492,6 @@ ...@@ -462,22 +492,6 @@
.upgrade-message { .upgrade-message {
flex-wrap: wrap; 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 { .action {
width: 100%; width: 100%;
margin: 5px 0; margin: 5px 0;
......
...@@ -20,6 +20,23 @@ ...@@ -20,6 +20,23 @@
<div><%- gettext('To complete the program, you must earn a verified certificate for each course.') %></div> <div><%- gettext('To complete the program, you must earn a verified certificate for each course.') %></div>
</div> </div>
<% } %> <% } %>
<% if (is_learner_eligible_for_one_click_purchase) { %>
<a href="<%- completeProgramURL %>" class="btn-brand btn cta-primary upgrade-button complete-program">
<%- gettext('Upgrade All Remaining Courses (')%>
<% if (discount_data.is_discounted) { %>
<span class='list-price'>
<%- StringUtils.interpolate(
gettext('${listPrice}'), {listPrice: discount_data.total_incl_tax_excl_discounts}
)
%>
</span>
<% } %>
<%- StringUtils.interpolate(
gettext(' ${price} {currency} )'), {price: full_program_price, currency: discount_data.currency}
)
%>
</a>
<% } %>
</div> </div>
<div class="course-list-headings"> <div class="course-list-headings">
<% if (inProgressCount) { %> <% if (inProgressCount) { %>
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<span class="price"> <%- price %></span> <span class="price"> <%- price %></span>
</div> </div>
<div class="action col-12 md-col-4"> <div class="action col-12 md-col-4">
<a href="<%- upgrade_url %>" class="btn-brand btn cta-primary upgrade-button"> <a href="<%- upgrade_url %>" class="btn-brand btn cta-primary upgrade-button single-course-run">
<%- gettext('Buy Certificate') %> <%- gettext('Upgrade to Verified') %>
<a> <a>
</div> </div>
...@@ -846,7 +846,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -846,7 +846,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
self.course_price = 100 self.course_price = 100
self.number_of_courses = 2 self.number_of_courses = 2
self.program = ProgramFactory( 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): def _create_course(self, course_price):
...@@ -940,7 +941,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -940,7 +941,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
""" """
Learner should be eligible for one click purchase if: Learner should be eligible for one click purchase if:
- program is eligible for one click purchase - 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() data = ProgramMarketingDataExtender(self.program, self.user).extend()
self.assertTrue(data['is_learner_eligible_for_one_click_purchase']) self.assertTrue(data['is_learner_eligible_for_one_click_purchase'])
...@@ -954,14 +955,17 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -954,14 +955,17 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase):
data = ProgramMarketingDataExtender(program, self.user).extend() data = ProgramMarketingDataExtender(program, self.user).extend()
self.assertFalse(data['is_learner_eligible_for_one_click_purchase']) self.assertFalse(data['is_learner_eligible_for_one_click_purchase'])
course = self._create_course(self.course_price) course1 = self._create_course(self.course_price)
CourseEnrollmentFactory(user=self.user, course_id=course['course_runs'][0]['key']) 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( program2 = ProgramFactory(
courses=[course], courses=[course1, course2],
is_program_eligible_for_one_click_purchase=True is_program_eligible_for_one_click_purchase=True,
applicable_seat_types=['verified'],
) )
data = ProgramMarketingDataExtender(program2, self.user).extend() 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): def test_multiple_published_course_runs(self):
""" """
...@@ -993,7 +997,8 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -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() data = ProgramMarketingDataExtender(program, self.user).extend()
...@@ -1065,6 +1070,7 @@ class TestProgramMarketingDataExtender(ModuleStoreTestCase): ...@@ -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. 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() data = ProgramMarketingDataExtender(self.program, self.user).extend()
self.assertEqual(len(data['skus']), 0) self.assertEqual(len(data['skus']), 0)
......
...@@ -600,10 +600,17 @@ class ProgramMarketingDataExtender(ProgramDataExtender): ...@@ -600,10 +600,17 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
skus = [] skus = []
if is_learner_eligible_for_one_click_purchase: if is_learner_eligible_for_one_click_purchase:
for course in self.data['courses']: for course in self.data['courses']:
is_learner_eligible_for_one_click_purchase = not any( add_course_sku = False
course_run['is_enrolled'] for course_run in course['course_runs'] for course_run in course['course_runs']:
(enrollment_mode, active) = CourseEnrollment.enrollment_mode_for_user(
self.user,
CourseKey.from_string(course_run['key'])
) )
if is_learner_eligible_for_one_click_purchase: 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']) published_course_runs = filter(lambda run: run['status'] == 'published', course['course_runs'])
if len(published_course_runs) == 1: if len(published_course_runs) == 1:
for seat in published_course_runs[0]['seats']: for seat in published_course_runs[0]['seats']:
...@@ -615,15 +622,16 @@ class ProgramMarketingDataExtender(ProgramDataExtender): ...@@ -615,15 +622,16 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
is_learner_eligible_for_one_click_purchase = False is_learner_eligible_for_one_click_purchase = False
skus = [] skus = []
break break
else:
skus = []
break
if skus: if skus:
try: try:
User = get_user_model() api_user = self.user
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME) if not self.user.is_authenticated():
api = ecommerce_api_client(service_user) 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 # Make an API call to calculate the discounted price
discount_data = api.baskets.calculate.get(sku=skus) discount_data = api.baskets.calculate.get(sku=skus)
...@@ -639,6 +647,8 @@ class ProgramMarketingDataExtender(ProgramDataExtender): ...@@ -639,6 +647,8 @@ class ProgramMarketingDataExtender(ProgramDataExtender):
}) })
except (ConnectionError, SlumberBaseException, Timeout): except (ConnectionError, SlumberBaseException, Timeout):
log.exception('Failed to get discount price for following product SKUs: %s ', ', '.join(skus)) 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({ self.data.update({
'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase, 'is_learner_eligible_for_one_click_purchase': is_learner_eligible_for_one_click_purchase,
......
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