Implemented an upgrade verification sock.
This sock sits at the bottom of both the home and the course content pages. It allows the user to click a 'Learn More' button to open a panel that allows the user to navigate to the upgrade checkout page. The sock is only shown for users that have not yet upgraded in a course that has a verification upgrade date that has not yet passed. Python tests cover the various course mode and upgrade dates.
This commit is contained in:
@@ -19,6 +19,9 @@ WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='course_experience')
|
||||
# Waffle flag to enable a single unified "Course" tab.
|
||||
UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'unified_course_tab')
|
||||
|
||||
# Waffle flag to enable the sock on the footer of the home and courseware pages
|
||||
DISPLAY_COURSE_SOCK = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'display_course_sock')
|
||||
|
||||
|
||||
def course_home_page_title(course): # pylint: disable=unused-argument
|
||||
"""
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,79 @@
|
||||
/* globals Logger */
|
||||
|
||||
export class CourseSock { // eslint-disable-line import/prefer-default-export
|
||||
constructor() {
|
||||
const $toggleActionButton = $('.action-toggle-verification-sock');
|
||||
const $verificationSock = $('.verification-sock .verification-main-panel');
|
||||
const $upgradeToVerifiedButton = $('.verification-sock .action-upgrade-certificate');
|
||||
const pageLocation = window.location.href.indexOf('courseware') > -1
|
||||
? 'Course Content Page' : 'Home Page';
|
||||
|
||||
// Behavior to fix button to bottom of screen on scroll
|
||||
const fixUpgradeButton = () => {
|
||||
if (!$upgradeToVerifiedButton.is(':visible')) return;
|
||||
|
||||
// Grab the current scroll location
|
||||
const documentBottom = $(window).scrollTop() + $(window).height();
|
||||
|
||||
// Establish a sliding window in which the button is fixed
|
||||
const startFixed = $verificationSock.offset().top + 320;
|
||||
const endFixed = (startFixed + $verificationSock.height()) - 220;
|
||||
|
||||
// Assure update button stays in sock even when max-width is exceeded
|
||||
const distLeft = ($verificationSock.offset().left + $verificationSock.width())
|
||||
- ($upgradeToVerifiedButton.width() + 22);
|
||||
|
||||
// Update positioning when scrolling is in fixed window and screen width is sufficient
|
||||
if ((documentBottom > startFixed && documentBottom < endFixed)
|
||||
|| $(window).width() < 960) {
|
||||
$upgradeToVerifiedButton.addClass('attached');
|
||||
$upgradeToVerifiedButton.css('left', `${distLeft}px`);
|
||||
} else {
|
||||
// If outside sliding window, reset to un-attached state
|
||||
$upgradeToVerifiedButton.removeClass('attached');
|
||||
$upgradeToVerifiedButton.css('left', 'auto');
|
||||
|
||||
// Add class to define absolute location
|
||||
if (documentBottom < startFixed) {
|
||||
$upgradeToVerifiedButton.addClass('stuck-top');
|
||||
$upgradeToVerifiedButton.removeClass('stuck-bottom');
|
||||
} else if (documentBottom > endFixed) {
|
||||
$upgradeToVerifiedButton.addClass('stuck-bottom');
|
||||
$upgradeToVerifiedButton.removeClass('stuck-top');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fix the sock to the screen on scroll and resize events
|
||||
if ($upgradeToVerifiedButton.length) {
|
||||
$(window).scroll(fixUpgradeButton).resize(fixUpgradeButton);
|
||||
}
|
||||
|
||||
// Open the sock when user clicks to Learn More
|
||||
$toggleActionButton.on('click', () => {
|
||||
const toggleSpeed = 400;
|
||||
$toggleActionButton.toggleClass('active').toggleClass('aria-expanded');
|
||||
$verificationSock.slideToggle(toggleSpeed, fixUpgradeButton);
|
||||
|
||||
// Log open and close events
|
||||
const isOpening = $toggleActionButton.hasClass('active');
|
||||
const logMessage = isOpening ? 'User opened the verification sock.'
|
||||
: 'User closed the verification sock.';
|
||||
Logger.log(
|
||||
logMessage,
|
||||
{
|
||||
from_page: pageLocation,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
$upgradeToVerifiedButton.on('click', () => {
|
||||
Logger.log(
|
||||
'User clicked the upgrade button in the verification sock.',
|
||||
{
|
||||
from_page: pageLocation,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,5 +97,6 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
${HTML(course_sock_fragment.body_html())}
|
||||
</div>
|
||||
</%block>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
## mako
|
||||
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
<%!
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
from openedx.features.course_experience import DISPLAY_COURSE_SOCK
|
||||
%>
|
||||
|
||||
<%block name="content">
|
||||
% if show_course_sock and DISPLAY_COURSE_SOCK.is_enabled(course_id):
|
||||
<div class="verification-sock">
|
||||
<button type="button" class="btn btn-brand focusable action-toggle-verification-sock">
|
||||
Learn About Verified Certificate
|
||||
</button>
|
||||
<div class="verification-main-panel">
|
||||
<div class="verification-desc-panel content-main">
|
||||
<h2>edX Verified Certificate</h2>
|
||||
<h4>Why upgrade?</h4>
|
||||
<ul>
|
||||
<li>Official proof of completion</li>
|
||||
<li>Easily shareable certificate</li>
|
||||
<li>Proven motivator to complete the course</li>
|
||||
<li>Certificate purchases help edX continue to offer free courses</li>
|
||||
</ul>
|
||||
<h4>How it works</h4>
|
||||
<ul>
|
||||
<li>Pay the Verified Certificate upgrade fee</li>
|
||||
<li>Verify your identity with a webcam and government-issued ID</li>
|
||||
<li>Study hard and pass the course</li>
|
||||
<li>Share your certificate with friends, employers, and others</li>
|
||||
</ul>
|
||||
<h4>edX Learner Stories</h4>
|
||||
<div class="learner-story-container">
|
||||
<img class="student-image" alt="Student Image" src="${static.url('course_experience/images/learner-quote.png')}" />
|
||||
<div class="story-quote">
|
||||
My certificate has helped me showcase my knowledge on my
|
||||
resume - I feel like this certificate could really help me land
|
||||
my dream job!
|
||||
<span class="author">- Christina Fong, edX Learner</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="learner-story-container">
|
||||
<img class="student-image" alt="Student Image" src="${static.url('course_experience/images/learner-quote2.png')}" />
|
||||
<div class="story-quote">
|
||||
I wanted to include a verified certificate on my resume and my profile to
|
||||
illustrate that I am working towards this goal I have and that I have
|
||||
achieved something while I was unemployed.</br>
|
||||
<span class="author">- Cheryl Troell, edX Learner</span>
|
||||
</div>
|
||||
</div>
|
||||
<img class="mini-cert" src="${static.url('course_experience/images/verified-cert.png')}"/>
|
||||
<a href="/verify_student/upgrade/${course_id}/">
|
||||
<button type="button" class="btn btn-brand stuck-top focusable action-upgrade-certificate">
|
||||
Upgrade Now (${HTML(course_price)})
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
</%block>
|
||||
|
||||
<%static:webpack entry="CourseSock">
|
||||
new CourseSock({
|
||||
el:'.verification-sock'
|
||||
});
|
||||
</%static:webpack>
|
||||
@@ -89,7 +89,7 @@ class TestCourseHomePage(SharedModuleStoreTestCase):
|
||||
course_home_url(self.course)
|
||||
|
||||
# Fetch the view and verify the query counts
|
||||
with self.assertNumQueries(45):
|
||||
with self.assertNumQueries(47):
|
||||
with check_mongo_calls(5):
|
||||
url = course_home_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Tests for course verification sock
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import ddt
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
|
||||
from openedx.features.course_experience import DISPLAY_COURSE_SOCK
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from .test_course_home import course_home_url
|
||||
|
||||
TEST_PASSWORD = 'test'
|
||||
TEST_VERIFICATION_SOCK_LOCATOR = '<div class="verification-sock">'
|
||||
TEST_COURSE_PRICE = 50
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCourseSockView(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the course verification sock fragment view.
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestCourseSockView, cls).setUpClass()
|
||||
|
||||
# Create four courses
|
||||
cls.standard_course = CourseFactory.create()
|
||||
cls.verified_course = CourseFactory.create()
|
||||
cls.verified_course_update_expired = CourseFactory.create()
|
||||
cls.verified_course_already_enrolled = CourseFactory.create()
|
||||
|
||||
# Assign each verifiable course a upgrade deadline
|
||||
cls._add_course_mode(cls.verified_course, upgrade_deadline_expired=False)
|
||||
cls._add_course_mode(cls.verified_course_update_expired, upgrade_deadline_expired=True)
|
||||
cls._add_course_mode(cls.verified_course_already_enrolled, upgrade_deadline_expired=False)
|
||||
|
||||
def setUp(self):
|
||||
super(TestCourseSockView, self).setUp()
|
||||
self.user = UserFactory.create()
|
||||
|
||||
# Enroll the user in the four courses
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.standard_course.id)
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.verified_course.id)
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.verified_course_update_expired.id)
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.verified_course_already_enrolled.id, mode=CourseMode.VERIFIED)
|
||||
|
||||
# Log the user in
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK, active=True)
|
||||
def test_standard_course(self):
|
||||
"""
|
||||
Assure that a course that cannot be verified does
|
||||
not have a visible verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.standard_course))
|
||||
self.assertEqual(self.is_verified_sock_visible(response), False,
|
||||
'Student should not be able to see sock in a unverifiable course.')
|
||||
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK, active=True)
|
||||
def test_verified_course(self):
|
||||
"""
|
||||
Assure that a course that can be verified has a
|
||||
visible verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.verified_course))
|
||||
self.assertEqual(self.is_verified_sock_visible(response), True,
|
||||
'Student should be able to see sock in a verifiable course.')
|
||||
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK, active=True)
|
||||
def test_verified_course_updated_expired(self):
|
||||
"""
|
||||
Assure that a course that has an expired upgrade
|
||||
date does not display the verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.verified_course_update_expired))
|
||||
self.assertEqual(self.is_verified_sock_visible(response), False,
|
||||
'Student should be able to see sock in a verifiable course if the update expiration date has passed.')
|
||||
|
||||
@override_waffle_flag(DISPLAY_COURSE_SOCK, active=True)
|
||||
def test_verified_course_user_already_upgraded(self):
|
||||
"""
|
||||
Assure that a user that has already upgraded to a
|
||||
verified status cannot see the verification sock.
|
||||
"""
|
||||
response = self.client.get(course_home_url(self.verified_course_already_enrolled))
|
||||
self.assertEqual(self.is_verified_sock_visible(response), False,
|
||||
'Student should be able to see sock if they have already upgraded to verified mode.')
|
||||
|
||||
@classmethod
|
||||
def is_verified_sock_visible(cls, response):
|
||||
return TEST_VERIFICATION_SOCK_LOCATOR in response.content
|
||||
|
||||
@classmethod
|
||||
def _add_course_mode(cls, course, upgrade_deadline_expired=False):
|
||||
"""
|
||||
Adds a course mode to the test course.
|
||||
"""
|
||||
upgrade_exp_date = datetime.datetime.now()
|
||||
if upgrade_deadline_expired:
|
||||
upgrade_exp_date = upgrade_exp_date - datetime.timedelta(days=21)
|
||||
else:
|
||||
upgrade_exp_date = upgrade_exp_date + datetime.timedelta(days=21)
|
||||
|
||||
CourseMode(
|
||||
course_id=course.id,
|
||||
mode_slug=CourseMode.VERIFIED,
|
||||
mode_display_name="Verified Certificate",
|
||||
min_price=TEST_COURSE_PRICE,
|
||||
_expiration_datetime=upgrade_exp_date, # pylint: disable=protected-access
|
||||
).save()
|
||||
@@ -8,6 +8,7 @@ from views.course_home import CourseHomeFragmentView, CourseHomeView
|
||||
from views.course_outline import CourseOutlineFragmentView
|
||||
from views.course_updates import CourseUpdatesFragmentView, CourseUpdatesView
|
||||
from views.welcome_message import WelcomeMessageFragmentView
|
||||
from views.course_sock import CourseSockFragmentView
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
@@ -40,4 +41,9 @@ urlpatterns = [
|
||||
WelcomeMessageFragmentView.as_view(),
|
||||
name='openedx.course_experience.welcome_message_fragment_view',
|
||||
),
|
||||
url(
|
||||
r'course_sock_fragment$',
|
||||
CourseSockFragmentView.as_view(),
|
||||
name='openedx.course_experience.course_sock_fragment_view',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..utils import get_course_outline_block_tree
|
||||
from .course_dates import CourseDatesFragmentView
|
||||
from .course_outline import CourseOutlineFragmentView
|
||||
from .welcome_message import WelcomeMessageFragmentView
|
||||
from .course_sock import CourseSockFragmentView
|
||||
|
||||
|
||||
class CourseHomeView(CourseTabView):
|
||||
@@ -105,6 +106,9 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
# TODO: Use get_course_overview_with_access and blocks api
|
||||
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True)
|
||||
|
||||
# Render the verification sock as a fragment
|
||||
course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs)
|
||||
|
||||
# Get the handouts
|
||||
handouts_html = get_course_info_section(request, request.user, course, 'handouts')
|
||||
|
||||
@@ -119,6 +123,7 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
'resume_course_url': resume_course_url,
|
||||
'dates_fragment': dates_fragment,
|
||||
'welcome_message_fragment': welcome_message_fragment,
|
||||
'course_sock_fragment': course_sock_fragment,
|
||||
'disable_courseware_js': True,
|
||||
'uses_pattern_library': True,
|
||||
}
|
||||
|
||||
56
openedx/features/course_experience/views/course_sock.py
Normal file
56
openedx/features/course_experience/views/course_sock.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Fragment for rendering the course's sock and associated toggle button.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.date_summary import VerifiedUpgradeDeadlineDate
|
||||
from courseware.courses import get_course_with_access
|
||||
from courseware.views.views import get_course_prices
|
||||
from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
|
||||
|
||||
class CourseSockFragmentView(EdxFragmentView):
|
||||
"""
|
||||
A fragment to provide extra functionality in a dropdown sock.
|
||||
"""
|
||||
def render_to_fragment(self, request, course, **kwargs):
|
||||
"""
|
||||
Render the course's sock fragment.
|
||||
"""
|
||||
context = self.get_verification_context(request, course)
|
||||
html = render_to_string('course_experience/course-sock-fragment.html', context)
|
||||
return Fragment(html)
|
||||
|
||||
def get_verification_context(self, request, course):
|
||||
course_key = CourseKey.from_string(unicode(course.id))
|
||||
|
||||
# Establish whether the course has a verified mode
|
||||
available_modes = CourseMode.modes_for_course_dict(unicode(course.id))
|
||||
has_verified_mode = CourseMode.has_verified_mode(available_modes)
|
||||
|
||||
# Establish whether the user is already enrolled
|
||||
is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user.id, course_key)
|
||||
|
||||
# Establish whether the verification deadline has already passed
|
||||
verification_deadline = VerifiedUpgradeDeadlineDate(course, request.user)
|
||||
deadline_has_passed = verification_deadline.deadline_has_passed()
|
||||
|
||||
show_course_sock = has_verified_mode and not is_already_verified and not deadline_has_passed
|
||||
|
||||
# Get the price of the course and format correctly
|
||||
course_prices = get_course_prices(course)
|
||||
|
||||
context = {
|
||||
'show_course_sock': show_course_sock,
|
||||
'course_price': course_prices[1],
|
||||
'course_id': course.id
|
||||
}
|
||||
|
||||
return context
|
||||
Reference in New Issue
Block a user