diff --git a/common/static/sass/edx-pattern-library-shims/_buttons.scss b/common/static/sass/edx-pattern-library-shims/_buttons.scss index 08eb3361dd709dd10c2865b9a7215c8a552b98a9..19bcdea1e9f955b03650fbb359086cc66f90ba0d 100644 --- a/common/static/sass/edx-pattern-library-shims/_buttons.scss +++ b/common/static/sass/edx-pattern-library-shims/_buttons.scss @@ -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; diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 2e0b2dc007229296a638cfcf2b30d84b3d527bb4..c0b552659cdfecf16d28d18a371f5298aedb9372 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -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' diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index f186277f7148dcb1cfa4453df1092f2bfecb1d42..5c8fdce518e22e2ef2ee0d528b9d30d7b7821156 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -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): diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 9709c6ad501a2df27b2e05a75cbe1dc7050073cd..62e8593081a2a75f755a0e18fec2cd7d522420cd 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -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) diff --git a/lms/static/sass/_build-course.scss b/lms/static/sass/_build-course.scss index d073a2c27b84767e678b14a69add5b747b29dfec..c23b8dad174ebb8b23d4062e69e4e8172344e480 100644 --- a/lms/static/sass/_build-course.scss +++ b/lms/static/sass/_build-course.scss @@ -68,3 +68,6 @@ // responsive @import 'base/layouts'; // temporary spot for responsive course + +// features +@import 'features/course-sock'; diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss index 3c1e078fdc6b308bd01bfb76e292c7b0a692d969..595cfd354296e4765112e16eea8f881825e05181 100644 --- a/lms/static/sass/_build-lms-v2.scss +++ b/lms/static/sass/_build-lms-v2.scss @@ -27,3 +27,4 @@ @import 'features/bookmarks'; @import 'features/course-experience'; @import 'features/course-search'; +@import 'features/course-sock'; diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index 0337bf41d3da382487a057db32172195c8cafd08..a702fe5858447b5d4353a1257f8db8381da695a4 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -107,6 +107,10 @@ border: 1px solid $lms-active-color; } } + + &:last-child { + border-bottom: none; + } } } } @@ -186,4 +190,3 @@ } } } - diff --git a/lms/static/sass/features/_course-sock.scss b/lms/static/sass/features/_course-sock.scss new file mode 100644 index 0000000000000000000000000000000000000000..a457033a451877f8955fa3f0120ca9bcb1928223 --- /dev/null +++ b/lms/static/sass/features/_course-sock.scss @@ -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; + } + } +} diff --git a/lms/static/sass/partials/base/_variables.scss b/lms/static/sass/partials/base/_variables.scss index 76becc6145fffea5db97d801da50a498a0535e3d..00aa6d412f49434fdab2e9799525dea0df8edf67 100644 --- a/lms/static/sass/partials/base/_variables.scss +++ b/lms/static/sass/partials/base/_variables.scss @@ -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 diff --git a/lms/static/sass/shared-v2/_variables.scss b/lms/static/sass/shared-v2/_variables.scss index 503efd066bb7e5f7f96116fac98c3a2ca632783b..34a2cd7b9ae795719e1a4031421dfe20a0080aa4 100644 --- a/lms/static/sass/shared-v2/_variables.scss +++ b/lms/static/sass/shared-v2/_variables.scss @@ -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; diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index b7e9ed4e8cb370fb111c3d70a9305f2e6d5c1288..08973de4390ea10dd996fc421ec7a9440619a578 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -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): diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index 795b329138115b8edce1c174fb4d59a96eb94e65..2fa75ecee2e0a6cf56309a48698b3abcb8ec28f6 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -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 """ diff --git a/openedx/features/course_experience/static/course_experience/images/learner-quote.png b/openedx/features/course_experience/static/course_experience/images/learner-quote.png new file mode 100644 index 0000000000000000000000000000000000000000..46918fa92b59f1afc4f6d86f8002a27a7d4015a2 Binary files /dev/null and b/openedx/features/course_experience/static/course_experience/images/learner-quote.png differ diff --git a/openedx/features/course_experience/static/course_experience/images/learner-quote2.png b/openedx/features/course_experience/static/course_experience/images/learner-quote2.png new file mode 100644 index 0000000000000000000000000000000000000000..b73d71a7732d8ea30f1cde73ef7c5f7dd52de48d Binary files /dev/null and b/openedx/features/course_experience/static/course_experience/images/learner-quote2.png differ diff --git a/openedx/features/course_experience/static/course_experience/images/verified-cert.png b/openedx/features/course_experience/static/course_experience/images/verified-cert.png new file mode 100644 index 0000000000000000000000000000000000000000..79a661a69c7ad3dd8b13f0263ae9e78614a67721 Binary files /dev/null and b/openedx/features/course_experience/static/course_experience/images/verified-cert.png differ diff --git a/openedx/features/course_experience/static/course_experience/js/CourseSock.js b/openedx/features/course_experience/static/course_experience/js/CourseSock.js new file mode 100644 index 0000000000000000000000000000000000000000..f654694fe21375347e573d4c663a31d5ba8e4865 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/CourseSock.js @@ -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, + }, + ); + }); + } +} diff --git a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html index 85ce1b1bd95e10824319c0d79c90920ce336b651..5aa13184aefe07d4886ef60020a51264683bfcf0 100644 --- a/openedx/features/course_experience/templates/course_experience/course-home-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-home-fragment.html @@ -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> diff --git a/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html b/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html new file mode 100644 index 0000000000000000000000000000000000000000..9b3f89b5995457f18f5544971b2137683562560d --- /dev/null +++ b/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html @@ -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> diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index 9b4eaee9ef38b83e74bacc3fbab44ee52c73595e..491afbe37d1089c874b57ad465269179b7167e18 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -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) diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py new file mode 100644 index 0000000000000000000000000000000000000000..6da4e05fb26e1a6bd8573898f240c84f226419f6 --- /dev/null +++ b/openedx/features/course_experience/tests/views/test_course_sock.py @@ -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() diff --git a/openedx/features/course_experience/urls.py b/openedx/features/course_experience/urls.py index 42372fb9f832c8398bf454b253a08fe09aa8d615..39d5ecf83354196d338bcdcaa9383eade3b34a16 100644 --- a/openedx/features/course_experience/urls.py +++ b/openedx/features/course_experience/urls.py @@ -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', + ), ] diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py index aa3922fc264769f969cdfbff264ae97018d456e4..aa6a43fbf6e359ef8cf1c852d1e4b4b5617c7477 100644 --- a/openedx/features/course_experience/views/course_home.py +++ b/openedx/features/course_experience/views/course_home.py @@ -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, } diff --git a/openedx/features/course_experience/views/course_sock.py b/openedx/features/course_experience/views/course_sock.py new file mode 100644 index 0000000000000000000000000000000000000000..57863ed3a354613cfb35e2888f62e289db53850c --- /dev/null +++ b/openedx/features/course_experience/views/course_sock.py @@ -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 diff --git a/webpack.config.js b/webpack.config.js index 5a5800a3f2fba97cd543589834d510e598f03316..6719071a23cafd6617b50725ac9a6ce290dd406c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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' },