Commit f226caf5 by Harry Rein Committed by GitHub

Merge pull request #15262 from edx/HarryRein/LEARNER-531-upgrade-sock

Adding waffle-protected upgrade sock to the unified-course home and courseware pages.
parents 691d6872 bdf38ae0
......@@ -10,6 +10,8 @@
// ----------------------------
%btn-shims {
display: inline-block;
background-color: transparent;
background-image: none;
border-style: $btn-border-style;
border-radius: $btn-border-radius;
border-width: $btn-border-size;
......
......@@ -118,6 +118,14 @@ class DateSummary(object):
return datetime.now(utc) <= self.date
return False
def deadline_has_passed(self):
"""
Return True if a deadline (the date) exists, and has already passed.
Returns False otherwise.
"""
deadline = self.date
return deadline is not None and deadline <= datetime.now(utc)
def __repr__(self):
return u'DateSummary: "{title}" {date} is_enabled={is_enabled}'.format(
title=self.title,
......@@ -313,13 +321,6 @@ class VerificationDeadlineDate(DateSummary):
"""Return the verification status for this user."""
return SoftwareSecurePhotoVerification.user_status(self.user)[0]
def deadline_has_passed(self):
"""
Return True if a verification deadline exists, and has already passed.
"""
deadline = self.date
return deadline is not None and deadline <= datetime.now(utc)
def must_retry(self):
"""Return True if the user must re-submit verification, False otherwise."""
return self.verification_status == 'must_reverify'
......@@ -209,8 +209,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 147),
(ModuleStoreEnum.Type.split, 4, 147),
(ModuleStoreEnum.Type.mongo, 10, 149),
(ModuleStoreEnum.Type.split, 4, 149),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
......
......@@ -33,6 +33,7 @@ from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from openedx.features.course_experience import UNIFIED_COURSE_VIEW_FLAG, default_course_url_name
from openedx.features.enterprise_support.api import data_sharing_consent_required
from openedx.features.course_experience.views.course_sock import CourseSockFragmentView
from request_cache.middleware import RequestCache
from shoppingcart.models import CourseRegistrationCode
from student.views import is_course_blocked
......@@ -367,6 +368,9 @@ class CoursewareIndex(View):
table_of_contents['chapters'],
)
courseware_context['course_sock_fragment'] = CourseSockFragmentView().render_to_fragment(
request, course=self.course)
# entrance exam data
self._add_entrance_exam_to_context(courseware_context)
......
......@@ -68,3 +68,6 @@
// responsive
@import 'base/layouts'; // temporary spot for responsive course
// features
@import 'features/course-sock';
......@@ -27,3 +27,4 @@
@import 'features/bookmarks';
@import 'features/course-experience';
@import 'features/course-search';
@import 'features/course-sock';
......@@ -107,6 +107,10 @@
border: 1px solid $lms-active-color;
}
}
&:last-child {
border-bottom: none;
}
}
}
}
......@@ -186,4 +190,3 @@
}
}
}
.verification-sock {
display: inline-block;
position: relative;
width: 100%;
margin-top: $baseline;
max-width: $lms-max-width;
margin: $baseline auto 0;
-webkit-transition: all 0.4s ease-out;
-moz-transition: all 0.4s ease-out;
-o-transition: all 0.4s ease-out;
-ms-transition: all 0.4s ease-out;
transition: all 0.4s ease-out;
.action-toggle-verification-sock {
@include left(50%);
@include margin-left(-1 * $baseline * 15/2);
position: absolute;
top: (-1 * $baseline);
width: ($baseline * 15);
color: $button-bg-hover-color;
background-color: $success-color;
border-color: $success-color;
background-image: none;
box-shadow: none;
-webkit-transition: background-color 0.5s;
transition: background-color 0.5s;
&.active {
color: $success-color;
background-color: $button-bg-hover-color;
border-color: $success-color;
background-image: none;
box-shadow: none;
&:hover {
color: $button-bg-hover-color;
background-color: $success-color-hover;
border-color: $success-color-hover;
background-image: none;
box-shadow: none;
}
}
&:hover {
color: $button-bg-hover-color;
background-color: $success-color-hover;
border-color: $success-color-hover;
background-image: none;
box-shadow: none;
}
}
.verification-main-panel {
display: none;
overflow: hidden;
border-top: 1px solid $lms-border-color;
padding: ($baseline * 5/2) ($baseline * 2);
-webkit-transition: height ease-out;
transition: height ease-out;
.verification-desc-panel {
color: $black-t3;
position: relative;
@media (max-width: 960px) {
.mini-cert {
display: none;
border: 1px solid $black-t0;
}
}
.mini-cert {
@include right($baseline);
position: absolute;
top: $baseline;
width: ($baseline * 13);
}
h2 {
font-size: 1.5rem;
font-weight: 700;
}
h4 {
font-size: 1.25rem;
font-weight: 600;
}
.learner-story-container {
display: flex;
max-width: 630px;
.student-image {
margin: ($baseline / 4) $baseline 0 0;
height: ($baseline * 5/2);
width: ($baseline * 5/2);
}
.story-quote > .author{
display: block;
margin-top: ($baseline / 4);
font-weight: 600;
}
&:not(:first-child) {
margin-top: ($baseline * 2);
}
}
.action-upgrade-certificate {
position: absolute;
right: $baseline;
background-color: $success-color;
border-color: $success-color;
background-image: none;
box-shadow: none;
@media (max-width: 960px) {
& {
position: relative;
margin-top: ($baseline * 2);
}
}
@media (min-width: 960px) {
&.stuck-top {
bottom: auto;
top: $baseline * (52 / 5);
}
&.stuck-bottom {
top: auto;
bottom: $baseline * (-1 * 3/2);
}
&.attached {
@include right($baseline);
position: fixed;
bottom: $baseline;
top: auto;
}
}
&:hover {
background-color: $success-color-hover;
border-color: $success-color-hover;
}
}
}
}
}
// Overrides for the courseware page.
.view-courseware {
.verification-sock {
margin-top: 0;
border-top: none;
border-bottom: none;
.action-toggle-verification-sock {
top: (-1 * $baseline * 5/4);
&:not(.active) {
color: $button-bg-hover-color;
background-color: $success-color;
box-shadow: none;
border: 1px solid $success-color;
&:hover {
background-color: $success-color-hover;
}
}
}
.verification-main-panel {
border-top: 0;
border-bottom: 1px solid $lms-border-color;
}
}
}
......@@ -36,7 +36,7 @@ $fg-gutter: $gw-gutter !default;
$fg-max-columns: 12 !default;
$fg-max-width: 1400px !default;
$fg-min-width: 810px !default;
$lms-max-width: 1180px !default;
// ----------------------------
// #COLORS
......@@ -218,7 +218,7 @@ $active-color: $blue !default;
$highlight-color: rgb(255,255,0) !default;
$alert-color: rgb(212, 64, 64) !default;
$success-color: rgb(0, 155, 0) !default;
$success-color-hover: rgb(0, 129, 0) !default;
// ----------------------------
// #COLORS- EDX-SPECIFIC
......
......@@ -9,27 +9,38 @@
// ----------------------------
// #GRID
// ----------------------------
$lms-max-width: 1180px;
$lms-max-width: 1180px !default;
// ----------------------------
// #COLORS
// ----------------------------
$lms-gray: palette(grayscale, base);
$lms-background-color: palette(grayscale, x-back);
$lms-container-background-color: $white;
$lms-border-color: palette(grayscale, back);
$lms-label-color: palette(grayscale, black);
$lms-active-color: palette(primary, base);
$lms-preview-menu-color: #c8c8c8;
$white-transparent: rgba(255, 255, 255, 0);
$white-opacity-40: rgba(255, 255, 255, 0.4);
$white-opacity-60: rgba(255, 255, 255, 0.6);
$white-opacity-70: rgba(255, 255, 255, 0.7);
$white-opacity-80: rgba(255, 255, 255, 0.8);
$lms-gray: palette(grayscale, base) !default;
$lms-background-color: palette(grayscale, x-back) !default;
$lms-container-background-color: $white !default;
$lms-border-color: palette(grayscale, back) !default;
$lms-label-color: palette(grayscale, black) !default;
$lms-active-color: palette(primary, base) !default;
$lms-preview-menu-color: #c8c8c8 !default;
$success-color: palette(success, accent) !default;
$success-color-hover: palette(success, text) !default;
$light-grey-transparent: rgba(200,200,200, 0);
$light-grey-solid: rgba(200,200,200, 1);
$button-bg-hover-color: $white !default;
$white-transparent: rgba(255, 255, 255, 0) !default;
$white-opacity-40: rgba(255, 255, 255, 0.4) !default;
$white-opacity-60: rgba(255, 255, 255, 0.6) !default;
$white-opacity-70: rgba(255, 255, 255, 0.7) !default;
$white-opacity-80: rgba(255, 255, 255, 0.8) !default;
$black: rgb(0,0,0) !default;
$black-t0: rgba($black, 0.125) !default;
$black-t1: rgba($black, 0.25) !default;
$black-t2: rgba($black, 0.5) !default;
$black-t3: rgba($black, 0.75) !default;
$light-grey-transparent: rgba(200,200,200, 0) !default;
$light-grey-solid: rgba(200,200,200, 1) !default;
// ----------------------------
// #TYPOGRAPHY
......@@ -42,9 +53,10 @@ $font-bold: 700 !default;
// ----------------------------
// #ICONS
// ----------------------------
$lms-dark-icon-color: $white;
$lms-dark-icon-background-color: palette(grayscale, black);
// Icons
$lms-dark-icon-color: $white !default;
$lms-dark-icon-background-color: palette(grayscale, black) !default;
$site-status-color: rgb(182,37,103);
$site-status-color: rgb(182,37,103) !default;
$shadow-l1: rgba(0,0,0,0.1) !default;
......@@ -226,6 +226,7 @@ ${HTML(fragment.foot_html())}
</section>
</div>
${HTML(course_sock_fragment.body_html())}
</div>
<div class="container-footer">
% if settings.FEATURES.get("LICENSING", False):
......
......@@ -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
"""
......
/* 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>
## 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)
"""
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,
}
......
"""
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
......@@ -19,6 +19,7 @@ var wpconfig = {
entry: {
CourseOutline: './openedx/features/course_experience/static/course_experience/js/CourseOutline.js',
CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',
Import: './cms/static/js/features/import/factories/import.js'
},
......
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