Commit 0a6029f7 by Clinton Blackburn Committed by Clinton Blackburn

Added verified upgrade hero to course run homepage

Audit learners are now shown a prompt to upgrade to the verified track
of the course run. This message goes away after the learner upgrades.
parent ee01e3e0
......@@ -33,6 +33,7 @@ from django.db.models import Count
from django.db.models.signals import post_save, pre_save
from django.dispatch import Signal, receiver
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext_noop
from django_countries.fields import CountryField
......@@ -1688,6 +1689,10 @@ class CourseEnrollment(models.Model):
self._course_overview = None
return self._course_overview
@cached_property
def verified_mode(self):
return CourseMode.verified_mode_for_course(self.course_id)
@property
def upgrade_deadline(self):
"""
......@@ -1723,11 +1728,9 @@ class CourseEnrollment(models.Model):
pass
try:
verified_mode = CourseMode.verified_mode_for_course(self.course_id)
if verified_mode:
if self.verified_mode:
log.debug('Schedules: Defaulting to verified mode expiration date-time for %s.', self.course_id)
return verified_mode.expiration_datetime
return self.verified_mode.expiration_datetime
else:
log.debug('Schedules: No verified mode located for %s.', self.course_id)
except CourseMode.DoesNotExist:
......
......@@ -21,6 +21,7 @@
// Elements
@import 'notifications';
@import 'elements/controls';
@import 'elements-v2/pagination';
// Features
......@@ -28,6 +29,7 @@
@import 'features/course-experience';
@import 'features/course-search';
@import 'features/course-sock';
@import 'features/course-upgrade-message';
// Views
@import "views/program-marketing-page";
/*
NOTE: If you make significant changes to the design, remember to update the Segment event properties and change
the creative property. This will allow us to better track individual performance of each style of the message.
Search for the courseware_verified_certificate_upsell promotion ID.
*/
$upgrade-message-background-color: $blue-d1;
// Expanded upgrade message
.vc-message {
background: $blue-d1;
color: $white;
padding: $baseline;
position: relative;
margin: 0 0 $baseline;
// CSS animation for smooth height transition
-webkit-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
&:after {
content: "";
display: table;
clear: both;
}
// Message copy
.vc-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1rem;
@include float(left);
}
.vc-selling-points {
@include clear(left);
@include padding-left(0);
font-size: 0.825rem;
margin: 1rem 0;
display: table;
> .vc-selling-point {
list-style: none;
display: table-row;
&:before {
content: "\2022";
display: table-cell;
@include padding-right($baseline/2);
}
&:after {
content: "";
display: table-row;
height: 0.25rem;
}
}
}
img {
max-width: 100%;
}
// Show/hide button
.vc-toggle {
@include float(right);
color: $white;
}
// Upgrade Button
.btn-upgrade {
@extend %btn-primary-green;
background: $uxpl-green-base;
}
// Cert image
.vc-hero {
@include float(right);
@include padding-left(1rem);
clear: both;
width: 35%;
img {
max-width: 100%;
}
}
}
// Collapsed upgrade message
.vc-message.polite {
padding-top: $baseline/2;
padding-bottom: $baseline/2;
min-height: 46px;
display: flex;
flex-flow: row wrap;
align-items: center;
.vc-title {
margin: 0;
@include margin-right(auto);
}
.vc-cta {
@include margin-right(1rem);
}
.vc-toggle {
order: 99;
}
.vc-fade:not(.vc-polite-only) {
display: none;
}
}
@media (max-width: $bp-screen-md) {
.vc-message.polite .vc-title {
clear: both;
width: 100%;
margin-bottom: 1rem;
}
}
@media (max-width: $bp-screen-sm) {
.vc-message .vc-hero {
display: none !important;
}
}
......@@ -25,6 +25,8 @@ COURSE_PRE_START_ACCESS_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'pre_star
# Waffle flag to enable a review page link from the unified home page
SHOW_REVIEWS_TOOL_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_reviews_tool')
SHOW_UPGRADE_MSG_ON_COURSE_HOME = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_upgrade_msg_on_course_home')
# Waffle flag to switch between the 'welcome message' and 'latest update' on the course home page.
# Important Admin Note: This is meant to be configured using waffle_utils course
# override only. Either do not create the actual waffle flag, or be sure to unset the
......
......@@ -29,6 +29,30 @@
<div class="page-content">
<div class="layout layout-1t2t">
<main class="layout-col layout-col-b">
<div class="section">
<div class="vc-message tex2jax_ignore" role="group" aria-labelledby="vc-title" tabindex="-1" style="display: none;">
<h3 class="vc-title vc-fade vc-polite-only">Pursue a verified certificate</h3>
<button class="vc-toggle vc-fade vc-polite-only btn-link" type="button" aria-controls="moreinfo"
aria-expanded="true" aria-label="Show/Hide">Show less
</button>
<div class="vc-hero vc-fade">
<img src="img/sample-certificate.png"
alt="Sample verified certificate with your name, the course title, the logo of the institution and the signatures of the instructors for this course."/>
</div>
<ul class="vc-selling-points vc-fade">
<li class="vc-selling-point">Official proof of completion</li>
<li class="vc-selling-point">Easily shareable certificate</li>
<li class="vc-selling-point">Proven motivator to complete the course</li>
<li class="vc-selling-point">Certificate purchases help us continue to offer free courses</li>
</ul>
<div class="vc-cta vc-fade vc-polite-only">
<a class="btn btn-upgrade" href="#">Upgrade ($100)</a>
</div>
</div>
</div>
<div class="section section-dates">
<div class="welcome-message">
<div class="dismiss-message">
......
/* globals Logger */
/* globals gettext, Logger */
export class CourseHome { // eslint-disable-line import/prefer-default-export
constructor(options) {
this.courseRunKey = options.courseRunKey;
this.msgStateStorageKey = `course_experience.upgrade_msg.${this.courseRunKey}.collapsed`;
// Logging for 'Resume Course' or 'Start Course' button click
const $resumeCourseLink = $(options.resumeCourseLink);
$resumeCourseLink.on('click', (event) => {
......@@ -26,5 +29,97 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
},
);
});
$(document).ready(() => {
this.configureUpgradeMessage();
});
}
static fireSegmentEvent(event, properties) {
/* istanbul ignore next */
if (!window.analytics) {
return;
}
window.analytics.track(event, properties);
}
/**
* Persists the collapsed state of the upgrade message. If the message is collapsed,
* this information is persisted to local storage. Expanding the message *removes* the
* key from local storage.
*/
persistUpgradeMessageState(collapsed) {
if (window.localStorage) {
if (collapsed) {
window.localStorage.setItem(this.msgStateStorageKey, true);
} else {
window.localStorage.removeItem(this.msgStateStorageKey);
}
}
}
configureUpgradeMessage() {
const $vcMessage = $('.vc-message');
const $vcDismissToggle = $('.vc-toggle', $vcMessage);
const logEventProperties = { courseRunKey: this.courseRunKey };
const promotionEventProperties = {
promotion_id: 'courseware_verified_certificate_upsell',
creative: 'original_hero',
name: 'In-Course Verification Prompt',
position: 'hero',
};
CourseHome.fireSegmentEvent('Promotion Viewed', promotionEventProperties);
Logger.log('edx.course.upgrade.hero.displayed', logEventProperties);
// Get height of container and button
let vcHeight = $vcMessage.outerHeight();
// Update based on window
window.onresize = () => {
if (!$vcMessage.hasClass('polite')) {
vcHeight = $vcMessage.outerHeight();
}
};
function collapseMessage(duration = 400) {
$('.vc-fade').fadeOut(duration, () => {
$vcDismissToggle.text(gettext('Show more')).attr('aria-expanded', false);
$('.vc-polite-only').fadeIn(duration);
$vcMessage.height('auto').addClass('polite');
});
}
// Use the previously-persisted state to determine the initial display state of the message.
if (window.localStorage && window.localStorage.getItem(this.msgStateStorageKey)) {
collapseMessage(0);
}
$vcMessage.show();
$vcDismissToggle.click(() => {
if ($vcMessage.hasClass('polite')) {
// Expand message
Logger.log('edx.course.upgrade.hero.expanded', logEventProperties);
this.persistUpgradeMessageState(false);
$('.vc-fade').fadeOut(400);
$vcMessage.animate({ height: vcHeight }, 400, () => {
$vcMessage.height('auto').removeClass('polite');
$vcDismissToggle.text(gettext('Show less')).attr('aria-expanded', true);
$('.vc-fade').fadeIn(400);
});
} else {
// Collapse message
Logger.log('edx.course.upgrade.hero.collapsed', logEventProperties);
this.persistUpgradeMessageState(true);
collapseMessage();
}
});
$('.btn-upgrade', $vcMessage).click(() => {
CourseHome.fireSegmentEvent('Promotion Clicked', promotionEventProperties);
Logger.log('edx.course.upgrade.hero.clicked', logEventProperties);
});
}
}
......@@ -3,18 +3,21 @@
import { CourseHome } from '../CourseHome';
describe('Course Home factory', () => {
describe('Ensure course tool click logging', () => {
let home; // eslint-disable-line no-unused-vars
let home;
const runKey = 'course-v1:edX+DemoX+Demo_Course';
window.analytics = jasmine.createSpyObj('analytics', ['page', 'track', 'trackLink']);
beforeEach(() => {
loadFixtures('course_experience/fixtures/course-home-fragment.html');
home = new CourseHome({
resumeCourseLink: '.action-resume-course',
courseToolLink: '.course-tool-link',
});
spyOn(Logger, 'log');
beforeEach(() => {
loadFixtures('course_experience/fixtures/course-home-fragment.html');
spyOn(Logger, 'log');
home = new CourseHome({ // eslint-disable-line no-unused-vars
courseRunKey: runKey,
resumeCourseLink: '.action-resume-course',
courseToolLink: '.course-tool-link',
});
});
describe('Ensure course tool click logging', () => {
it('sends an event when resume or start course is clicked', () => {
$('.action-resume-course').click();
expect(Logger.log).toHaveBeenCalledWith(
......@@ -22,7 +25,7 @@ describe('Course Home factory', () => {
{
event_type: 'start',
url: `http://${window.location.host}/courses/course-v1:edX+DemoX+Demo_Course/courseware` +
'/19a30717eff543078a5d94ae9d6c18a5/',
'/19a30717eff543078a5d94ae9d6c18a5/',
},
);
});
......@@ -43,4 +46,57 @@ describe('Course Home factory', () => {
}
});
});
describe('Upgrade message events', () => {
const segmentEventProperties = {
promotion_id: 'courseware_verified_certificate_upsell',
creative: 'original_hero',
name: 'In-Course Verification Prompt',
position: 'hero',
};
it('should send events to Segment and edX on initial load', () => {
expect(window.analytics.track).toHaveBeenCalledWith('Promotion Viewed', segmentEventProperties);
expect(Logger.log).toHaveBeenCalledWith('edx.course.upgrade.hero.displayed', { courseRunKey: runKey });
});
it('should send events to Segment and edX after clicking the upgrade button ', () => {
$('.vc-message .btn-upgrade').click();
expect(window.analytics.track).toHaveBeenCalledWith('Promotion Viewed', segmentEventProperties);
expect(Logger.log).toHaveBeenCalledWith('edx.course.upgrade.hero.clicked', { courseRunKey: runKey });
});
});
describe('upgrade message display toggle', () => {
let $message;
let $toggle;
beforeEach(() => {
$.fx.off = true;
$message = $('.vc-message');
$toggle = $('.vc-toggle', $message);
expect($message.length).toEqual(1);
expect($toggle.length).toEqual(1);
});
it('hides/shows the message and writes/removes a key from local storage', () => {
// NOTE (CCB): Ideally this should be two tests--one for collapse, another for expansion.
// After a couple hours I have been unable to make these two tests pass, probably due to
// issues with the initial state of local storage.
expect($message.is(':visible')).toBeTruthy();
expect($message.hasClass('polite')).toBeFalsy();
expect($toggle.text().trim()).toEqual('Show less');
$toggle.click();
expect($message.hasClass('polite')).toBeTruthy();
expect($toggle.text().trim()).toEqual('Show more');
expect(window.localStorage.getItem(home.msgStateStorageKey)).toEqual('true');
$toggle.click();
expect($message.hasClass('polite')).toBeFalsy();
expect($toggle.text().trim()).toEqual('Show less');
expect(window.localStorage.getItem(home.msgStateStorageKey)).toBeNull();
});
});
});
......@@ -57,6 +57,37 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
<div class="page-content">
<div class="layout layout-1t2t">
<main class="layout-col layout-col-b">
% if upgrade_url and upgrade_price:
<div class="section">
<div class="vc-message tex2jax_ignore" role="group" aria-labelledby="vc-title" tabindex="-1" style="display: none;">
<h3 class="vc-title vc-fade vc-polite-only">Pursue a verified certificate</h3>
<button class="vc-toggle vc-fade vc-polite-only btn-link" type="button" aria-controls="moreinfo"
aria-expanded="true" aria-label="${_("Show/Hide")}">
${_("Show less")}
</button>
<div class="vc-hero vc-fade">
<img src="${static.url('course_experience/images/verified-cert.png')}"
alt="${_("Sample verified certificate with your name, the course title, the logo of the institution and the signatures of the instructors for this course.")}"/>
</div>
<ul class="vc-selling-points vc-fade">
<li class="vc-selling-point">${_("Official proof of completion")}</li>
<li class="vc-selling-point">${_("Easily shareable certificate")}</li>
<li class="vc-selling-point">${_("Proven motivator to complete the course")}</li>
<li class="vc-selling-point">${_("Certificate purchases help us continue to offer free courses")}</li>
</ul>
<div class="vc-cta vc-fade vc-polite-only">
<a class="btn-upgrade" href="${ upgrade_url }">${_("Upgrade ({price})").format(price='$' + str(upgrade_price))}</a>
</div>
</div>
</div>
% endif
% if course_home_message_fragment:
${HTML(course_home_message_fragment.body_html())}
% endif
......@@ -109,6 +140,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
<%static:webpack entry="CourseHome">
new CourseHome({
courseRunKey: "${course_key | n, js_escaped_string}",
resumeCourseLink: ".action-resume-course",
courseToolLink: ".course-tool-link",
});
......
......@@ -3,28 +3,36 @@
Tests for the course home page.
"""
from datetime import datetime, timedelta
import ddt
import mock
from pytz import UTC
from waffle.testutils import override_flag
from courseware.tests.factories import StaffFactory
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import QueryDict
from django.utils.http import urlquote_plus
from pytz import UTC
from waffle.models import Flag
from waffle.testutils import override_flag
from commerce.models import CommerceConfiguration
from commerce.utils import EcommerceService
from course_modes.models import CourseMode
from courseware.tests.factories import StaffFactory
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
from openedx.features.course_experience import SHOW_REVIEWS_TOOL_FLAG, UNIFIED_COURSE_TAB_FLAG
from openedx.features.course_experience import (
SHOW_REVIEWS_TOOL_FLAG,
SHOW_UPGRADE_MSG_ON_COURSE_HOME,
UNIFIED_COURSE_TAB_FLAG
)
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from util.date_utils import strftime_localized
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import CourseUserType, SharedModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
from ... import COURSE_PRE_START_ACCESS_FLAG
from .helpers import add_course_mode
from .test_course_updates import create_course_update
from ... import COURSE_PRE_START_ACCESS_FLAG
TEST_PASSWORD = 'test'
TEST_CHAPTER_NAME = 'Test Chapter'
......@@ -68,6 +76,7 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
"""
Base class for testing the course home page.
"""
@classmethod
def setUpClass(cls):
"""
......@@ -113,9 +122,6 @@ class CourseHomePageTestCase(SharedModuleStoreTestCase):
class TestCourseHomePage(CourseHomePageTestCase):
def setUp(self):
"""
Set up for the tests.
"""
super(TestCourseHomePage, self).setUp()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
......@@ -160,7 +166,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(41, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with self.assertNumQueries(42, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
......@@ -374,3 +380,75 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
response = self.client.get(url)
self.assertContains(response, TEST_COURSE_HOME_MESSAGE)
self.assertContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START)
class CourseHomeFragmentViewTests(ModuleStoreTestCase):
CREATE_USER = False
def setUp(self):
super(CourseHomeFragmentViewTests, self).setUp()
CommerceConfiguration.objects.create(checkout_on_ecommerce_service=True)
end = datetime.now(UTC) + timedelta(days=30)
self.course = CourseFactory(
start=datetime.now(UTC) - timedelta(days=30),
end=end,
)
self.url = course_home_url(self.course)
CourseMode.objects.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT)
self.verified_mode = CourseMode.objects.create(
course_id=self.course.id,
mode_slug=CourseMode.VERIFIED,
min_price=100,
expiration_datetime=end,
sku='test'
)
self.user = UserFactory()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
name = SHOW_UPGRADE_MSG_ON_COURSE_HOME.waffle_namespace._namespaced_name(
SHOW_UPGRADE_MSG_ON_COURSE_HOME.flag_name)
self.flag, __ = Flag.objects.update_or_create(name=name, defaults={'everyone': True})
def assert_upgrade_message_not_displayed(self):
response = self.client.get(self.url)
self.assertNotIn('vc-message', response.content)
def assert_upgrade_message_displayed(self):
response = self.client.get(self.url)
self.assertIn('vc-message', response.content)
url = EcommerceService().get_checkout_page_url(self.verified_mode.sku)
expected = '<a class="btn-upgrade" href="{url}">Upgrade (${price})</a>'.format(
url=url,
price=self.verified_mode.min_price
)
self.assertIn(expected, response.content)
def test_no_upgrade_message_if_logged_out(self):
self.client.logout()
self.assert_upgrade_message_not_displayed()
def test_no_upgrade_message_if_not_enrolled(self):
self.assertEqual(len(CourseEnrollment.enrollments_for_user(self.user)), 0)
self.assert_upgrade_message_not_displayed()
def test_no_upgrade_message_if_verified_track(self):
CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
self.assert_upgrade_message_not_displayed()
def test_no_upgrade_message_if_upgrade_deadline_passed(self):
self.verified_mode.expiration_datetime = datetime.now(UTC) - timedelta(days=20)
self.verified_mode.save()
self.assert_upgrade_message_not_displayed()
def test_no_upgrade_message_if_flag_disabled(self):
self.flag.everyone = False
self.flag.save()
CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT)
self.assert_upgrade_message_not_displayed()
def test_display_upgrade_message_if_audit_and_deadline_not_passed(self):
CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT)
self.assert_upgrade_message_displayed()
......@@ -9,6 +9,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from commerce.utils import EcommerceService
from courseware.access import has_access
from courseware.courses import (
can_self_enroll_in_course,
......@@ -24,7 +25,7 @@ from student.models import CourseEnrollment
from util.views import ensure_valid_course_key
from web_fragments.fragment import Fragment
from .. import LATEST_UPDATE_FLAG
from .. import LATEST_UPDATE_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME
from ..utils import get_course_outline_block_tree
from .course_dates import CourseDatesFragmentView
from .course_home_messages import CourseHomeMessageFragmentView
......@@ -116,9 +117,10 @@ class CourseHomeFragmentView(EdxFragmentView):
# Render the full content to enrolled users, as well as to course and global staff.
# Unenrolled users who are not course or global staff are given only a subset.
enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
user_access = {
'is_anonymous': request.user.is_anonymous(),
'is_enrolled': CourseEnrollment.is_enrolled(request.user, course_key),
'is_enrolled': enrollment is not None,
'is_staff': has_access(request.user, 'staff', course_key),
}
if user_access['is_enrolled'] or user_access['is_staff']:
......@@ -157,6 +159,22 @@ class CourseHomeFragmentView(EdxFragmentView):
request, course_id=course_id, user_access=user_access, **kwargs
)
# Get info for upgrade messaging
upgrade_price = None
upgrade_url = None
# TODO Add switch to control deployment
if SHOW_UPGRADE_MSG_ON_COURSE_HOME.is_enabled(course_key) and enrollment and enrollment.upgrade_deadline:
verified_mode = enrollment.verified_mode
if verified_mode:
upgrade_price = verified_mode.min_price
ecommerce_service = EcommerceService()
if ecommerce_service.is_enabled(request.user):
upgrade_url = ecommerce_service.get_checkout_page_url(verified_mode.sku)
else:
upgrade_url = reverse('verify_student_upgrade_and_verify', args=(course_key,))
# Render the course home fragment
context = {
'request': request,
......@@ -174,6 +192,8 @@ class CourseHomeFragmentView(EdxFragmentView):
'course_sock_fragment': course_sock_fragment,
'disable_courseware_js': True,
'uses_pattern_library': True,
'upgrade_price': upgrade_price,
'upgrade_url': upgrade_url,
}
html = render_to_string('course_experience/course-home-fragment.html', context)
return Fragment(html)
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