diff --git a/common/static/sass/edx-pattern-library-shims/_buttons.scss b/common/static/sass/edx-pattern-library-shims/_buttons.scss index 08eb3361dd..19bcdea1e9 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 2e0b2dc007..c0b552659c 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 f186277f71..5c8fdce518 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 9709c6ad50..62e8593081 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 d073a2c27b..c23b8dad17 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 3c1e078fdc..595cfd3542 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 0337bf41d3..a702fe5858 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 0000000000..a457033a45 --- /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 76becc6145..00aa6d412f 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 503efd066b..34a2cd7b9a 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 b7e9ed4e8c..08973de439 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -226,6 +226,7 @@ ${HTML(fragment.foot_html())} + ${HTML(course_sock_fragment.body_html())} + ${HTML(course_sock_fragment.body_html())} 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 0000000000..9b3f89b599 --- /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): +
+ +
+
+

edX Verified Certificate

+

Why upgrade?

+
    +
  • Official proof of completion
  • +
  • Easily shareable certificate
  • +
  • Proven motivator to complete the course
  • +
  • Certificate purchases help edX continue to offer free courses
  • +
+

How it works

+
    +
  • Pay the Verified Certificate upgrade fee
  • +
  • Verify your identity with a webcam and government-issued ID
  • +
  • Study hard and pass the course
  • +
  • Share your certificate with friends, employers, and others
  • +
+

edX Learner Stories

+
+ Student Image +
+ My certificate has helped me showcase my knowledge on my + resume - I feel like this certificate could really help me land + my dream job! + - Christina Fong, edX Learner +
+
+
+ Student Image +
+ 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.
+ - Cheryl Troell, edX Learner +
+
+ + + + +
+
+
+ % endif + + +<%static:webpack entry="CourseSock"> + new CourseSock({ + el:'.verification-sock' + }); + 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 9b4eaee9ef..491afbe37d 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 0000000000..6da4e05fb2 --- /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 = '
' +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 42372fb9f8..39d5ecf833 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 aa3922fc26..aa6a43fbf6 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 0000000000..57863ed3a3 --- /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 5a5800a3f2..6719071a23 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' },