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:
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
180
lms/static/sass/features/_course-sock.scss
Normal file
180
lms/static/sass/features/_course-sock.scss
Normal file
@@ -0,0 +1,180 @@
|
||||
.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
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user