Commit bf955d45 by McKenzie Welter Committed by GitHub

Merge pull request #15711 from edx/aj/LEARNER-1899

Added Program Purchase button to Programs dashboard with optional Program Completion
parents 4e183b41 de6d48a6
......@@ -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)
......@@ -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));
......
......@@ -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() {
......
......@@ -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;
......
......@@ -20,6 +20,23 @@
<div><%- gettext('To complete the program, you must earn a verified certificate for each course.') %></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 class="course-list-headings">
<% if (inProgressCount) { %>
......
......@@ -4,7 +4,7 @@
<span class="price"> <%- price %></span>
</div>
<div class="action col-12 md-col-4">
<a href="<%- upgrade_url %>" class="btn-brand btn cta-primary upgrade-button">
<%- gettext('Buy Certificate') %>
<a href="<%- upgrade_url %>" class="btn-brand btn cta-primary upgrade-button single-course-run">
<%- gettext('Upgrade to Verified') %>
<a>
</div>
......@@ -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)
......
......@@ -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,
......
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