diff --git a/common/static/sass/edx-pattern-library-shims/_buttons.scss b/common/static/sass/edx-pattern-library-shims/_buttons.scss index cb5e5d85dc4ccd369a68ebf51cb70c10e51caf1a..d0eaf87b925b3a2ea0a0eba68392fde10874fc2b 100644 --- a/common/static/sass/edx-pattern-library-shims/_buttons.scss +++ b/common/static/sass/edx-pattern-library-shims/_buttons.scss @@ -121,3 +121,32 @@ color: $btn-brand-disabled-color; } } + +// ---------------------------- +// #UPGRADE +// ---------------------------- +.btn-upgrade { + @extend %btn-shims; + + border-color: $btn-upgrade-border-color; + background: $btn-upgrade-background; + color: $btn-upgrade-color; + + // STATE: hover and focus + &:hover, + &.is-hovered, + &:focus, + &.is-focused { + border-color: $btn-upgrade-focus-border-color; + background-color: $btn-upgrade-focus-background; + color: $btn-upgrade-focus-color; + } + + // STATE: is disabled + &:disabled, + &.is-disabled { + border-color: $btn-disabled-border-color; + background: $btn-brand-disabled-background; + color: $btn-upgrade-color; + } +} diff --git a/common/static/sass/edx-pattern-library-shims/base/_variables.scss b/common/static/sass/edx-pattern-library-shims/base/_variables.scss index 3ae121632df9ac0bb452c09dc799589ea39b9c07..dba1a33ad579c508c7bab6a0ea1dd0f0e3f068da 100644 --- a/common/static/sass/edx-pattern-library-shims/base/_variables.scss +++ b/common/static/sass/edx-pattern-library-shims/base/_variables.scss @@ -143,9 +143,8 @@ $error-color: rgb(203, 7, 18) !default; $success-color: rgb(0, 155, 0) !default; $warning-color: rgb(255, 192, 31) !default; $warning-color-accent: rgb(255, 252, 221) !default; -$general-color: $uxpl-blue-base !default;; -$general-color-accent: $uxpl-blue-base !default - +$general-color: $uxpl-blue-base !default; +$general-color-accent: $uxpl-blue-base !default; // CAPA correctness color to be consistent with Alert styles above $correct: $success-color !default; @@ -181,6 +180,16 @@ $btn-brand-active-background: $uxpl-blue-base !default; $btn-brand-disabled-background: #f2f3f3 !default; $btn-brand-disabled-color: #676666 !default; +// Upgrade button +$btn-upgrade-border-color: $uxpl-green-base !default; +$btn-upgrade-background: $uxpl-green-base !default; +$btn-upgrade-color: #fcfcfc !default; +$btn-upgrade-focus-color: $btn-upgrade-color !default; +$btn-upgrade-focus-border-color: rgb(0, 155, 0) !default; +$btn-upgrade-focus-background: rgb(0, 155, 0) !default; +$btn-upgrade-active-border-color: $uxpl-green-base !default; +$btn-upgrade-active-background: $uxpl-green-base !default; + // ---------------------------- // #SETTINGS // ---------------------------- diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index 7de0e6d6f1f155e30b5354edeeb82207932c7d03..daf83e4d0b03f77777ec439a81a32787784a2dfe 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -4,6 +4,8 @@ from urlparse import urljoin import waffle from django.conf import settings +from django.core.urlresolvers import reverse +from student.models import CourseEnrollment from commerce.models import CommerceConfiguration from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -93,3 +95,16 @@ class EcommerceService(object): checkout_page_path=self.get_absolute_ecommerce_url(self.config.MULTIPLE_ITEMS_BASKET_PAGE_URL), skus=urlencode({'sku': skus}, doseq=True), ) + + def upgrade_url(self, user, course_key): + """ + Returns the URL for the user to upgrade, or None if not applicable. + """ + enrollment = CourseEnrollment.get_enrollment(user, course_key) + verified_mode = enrollment.verified_mode if enrollment else None + if verified_mode: + if self.is_enabled(user): + return self.get_checkout_page_url(verified_mode.sku) + else: + return reverse('verify_student_upgrade_and_verify', args=(course_key,)) + return None diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index d27bb188efced344c492d2dbd7f4d3e07776f1e7..205edbe3a858a309c2cd2191a554fd32ba1bdaac 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -3,26 +3,45 @@ This module provides date summary blocks for the Course Info page. Each block gives information about a particular course-run-specific date which will be displayed to the user. """ +import crum import datetime from babel.dates import format_timedelta + +from django.conf import settings from django.core.urlresolvers import reverse from django.utils.functional import cached_property from django.utils.translation import get_language, to_locale, ugettext_lazy from django.utils.translation import ugettext as _ from lazy import lazy -from pytz import timezone, utc +from pytz import utc -from course_modes.models import CourseMode +from course_modes.models import CourseMode, get_cosmetic_verified_display_price from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline from openedx.core.djangoapps.certificates.api import can_show_certificate_available_date_field +from openedx.core.djangolib.markup import HTML, Text +from openedx.features.course_experience import CourseHomeMessages, UPGRADE_DEADLINE_MESSAGE from student.models import CourseEnrollment +from .context_processor import user_timezone_locale_prefs + class DateSummary(object): """Base class for all date summary blocks.""" + # A consistent representation of the current time. + _current_time = None + + @property + def current_time(self): + """ + Returns a consistent current time. + """ + if self._current_time is None: + self._current_time = datetime.datetime.now(utc) + return self._current_time + @property def css_class(self): """ @@ -41,6 +60,12 @@ class DateSummary(object): """The detail text displayed by this summary.""" return '' + def register_alerts(self, request, course): + """ + Registers any relevant course alerts given the current request. + """ + pass + @property def date(self): """This summary's date.""" @@ -64,15 +89,6 @@ class DateSummary(object): """The text of the link.""" return '' - @property - def time_zone(self): - """ - The time zone in which to display -- defaults to UTC - """ - return timezone( - self.user.preferences.model.get_value(self.user, "time_zone", "UTC") - ) - def __init__(self, course, user, course_id=None): self.course = course self.user = user @@ -87,7 +103,7 @@ class DateSummary(object): if self.date is None: return '' locale = to_locale(get_language()) - delta = self.date - datetime.datetime.now(utc) + delta = self.date - self.current_time try: relative_date = format_timedelta(delta, locale=locale) # Babel doesn't have translations for Esperanto, so we get @@ -117,7 +133,7 @@ class DateSummary(object): future. """ if self.date is not None: - return datetime.datetime.now(utc).date() <= self.date.date() + return self.current_time.date() <= self.date.date() return False def deadline_has_passed(self): @@ -126,7 +142,52 @@ class DateSummary(object): Returns False otherwise. """ deadline = self.date - return deadline is not None and deadline <= datetime.datetime.now(utc) + return deadline is not None and deadline <= self.current_time + + @property + def time_remaining_string(self): + """ + Returns the time remaining as a localized string. + """ + locale = to_locale(get_language()) + return format_timedelta(self.date - self.current_time, locale=locale) + + def date_html(self, date_format='shortDate'): + """ + Returns a representation of the date as HTML. + + Note: this returns a span that will be localized on the client. + """ + locale = to_locale(get_language()) + user_timezone = user_timezone_locale_prefs(crum.get_current_request())['user_timezone'] + return HTML( + '<span class="date localized-datetime" data-format="{date_format}" data-datetime="{date_time}"' + ' data-timezone="{user_timezone}" data-language="{user_language}">' + '</span>' + ).format( + date_format=date_format, + date_time=self.date, + user_timezone=user_timezone, + user_language=locale, + ) + + @property + def long_date_html(self): + """ + Returns a long representation of the date as HTML. + + Note: this returns a span that will be localized on the client. + """ + return self.date_html(date_format='shortDate') + + @property + def short_time_html(self): + """ + Returns a short representation of the time as HTML. + + Note: this returns a span that will be localized on the client. + """ + return self.date_html(date_format='shortTime') def __repr__(self): return u'DateSummary: "{title}" {date} is_enabled={is_enabled}'.format( @@ -151,7 +212,7 @@ class TodaysDate(DateSummary): @property def date(self): - return datetime.datetime.now(utc) + return self.current_time @property def title(self): @@ -169,6 +230,35 @@ class CourseStartDate(DateSummary): def date(self): return self.course.start + def register_alerts(self, request, course): + """ + Registers an alert if the course has not started yet. + """ + is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id) + if not course.start or not is_enrolled: + return + days_until_start = (course.start - self.current_time).days + if course.start > self.current_time: + if days_until_start > 0: + CourseHomeMessages.register_info_message( + request, + Text(_( + "Don't forget to add a calendar reminder!" + )), + title=Text(_("Course starts in {time_remaining_string} on {course_start_date}.")).format( + time_remaining_string=self.time_remaining_string, + course_start_date=self.long_date_html, + ) + ) + else: + CourseHomeMessages.register_info_message( + request, + Text(_("Course starts in {time_remaining_string} at {course_start_time}.")).format( + time_remaining_string=self.time_remaining_string, + course_start_time=self.short_time_html, + ) + ) + class CourseEndDate(DateSummary): """ @@ -183,7 +273,7 @@ class CourseEndDate(DateSummary): @property def description(self): - if datetime.datetime.now(utc) <= self.date: + if self.current_time <= self.date: mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_id) if is_active and CourseMode.is_eligible_for_certificate(mode): return _('To earn a certificate, you must complete all requirements before this date.') @@ -195,6 +285,35 @@ class CourseEndDate(DateSummary): def date(self): return self.course.end + def register_alerts(self, request, course): + """ + Registers an alert if the end date is approaching. + """ + is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id) + if not course.start or self.current_time < course.start or not is_enrolled: + return + days_until_end = (course.end - self.current_time).days + if course.end > self.current_time and days_until_end <= settings.COURSE_MESSAGE_ALERT_DURATION_IN_DAYS: + if days_until_end > 0: + CourseHomeMessages.register_info_message( + request, + Text(self.description), + title=Text(_('This course is ending in {time_remaining_string} on {course_end_date}.')).format( + time_remaining_string=self.time_remaining_string, + course_end_date=self.long_date_html, + ) + ) + else: + CourseHomeMessages.register_info_message( + request, + Text(self.description), + title=Text(_('This course is ending in {time_remaining_string} at {course_end_time}.')).format( + time_remaining_string=self.time_remaining_string, + course_end_time=self.short_time_html, + ) + ) + + class CertificateAvailableDate(DateSummary): """ @@ -216,7 +335,7 @@ class CertificateAvailableDate(DateSummary): can_show_certificate_available_date_field(self.course) and self.has_certificate_modes and self.date is not None and - datetime.datetime.now(utc) <= self.date and + self.current_time <= self.date and len(self.active_certificates) > 0 ) @@ -252,13 +371,7 @@ class VerifiedUpgradeDeadlineDate(DateSummary): @property def link(self): - ecommerce_service = EcommerceService() - if ecommerce_service.is_enabled(self.user): - course_mode = CourseMode.objects.get( - course_id=self.course_id, mode_slug=CourseMode.VERIFIED - ) - return ecommerce_service.get_checkout_page_url(course_mode.sku) - return reverse('verify_student_upgrade_and_verify', args=(self.course_id,)) + return EcommerceService().upgrade_url(self.user, self.course_id) @cached_property def enrollment(self): @@ -299,6 +412,39 @@ class VerifiedUpgradeDeadlineDate(DateSummary): return deadline + def register_alerts(self, request, course): + """ + Registers an alert if the verification deadline is approaching. + """ + upgrade_price = get_cosmetic_verified_display_price(course) + if not UPGRADE_DEADLINE_MESSAGE.is_enabled(course.id) or not self.is_enabled or not upgrade_price: + return + days_left_to_upgrade = (self.date - self.current_time).days + if self.date > self.current_time and days_left_to_upgrade <= settings.COURSE_MESSAGE_ALERT_DURATION_IN_DAYS: + CourseHomeMessages.register_info_message( + request, + Text(_( + 'In order to qualify for a certificate, you must meet all course grading ' + 'requirements, upgrade before the course deadline, and successfully verify ' + 'your identity on {platform_name} if you have not done so already.{button_panel}' + )).format( + platform_name=settings.PLATFORM_NAME, + button_panel=HTML( + '<div class="message-actions">' + '<a class="btn btn-upgrade" href="{upgrade_url}">{upgrade_label}</a>' + '</div>' + ).format( + upgrade_url=self.link, + upgrade_label=Text(_('Upgrade ({upgrade_price})')).format(upgrade_price=upgrade_price), + ) + ), + title=Text(_( + "Don't forget, you have {time_remaining_string} left to upgrade to a Verified Certificate." + )).format( + time_remaining_string=self.time_remaining_string, + ) + ) + class VerificationDeadlineDate(DateSummary): """ diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 069edb6f2b9c0985f1d6449d8fb99c2308e39fdf..08c2f320e61c208f74ad830c2772a97e19830c0b 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -4,7 +4,9 @@ from datetime import datetime, timedelta import ddt import waffle +from django.contrib.messages.middleware import MessageMiddleware from django.core.urlresolvers import reverse +from django.test import RequestFactory, TestCase from freezegun import freeze_time from mock import patch from nose.plugins.attrib import attr @@ -31,7 +33,7 @@ from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag -from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG +from openedx.features.course_experience import CourseHomeMessages, UNIFIED_COURSE_TAB_FLAG, UPGRADE_DEADLINE_MESSAGE from student.tests.factories import CourseEnrollmentFactory, UserFactory, TEST_PASSWORD from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -46,20 +48,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): super(CourseDateSummaryTest, self).setUp() SelfPacedConfiguration.objects.create(enable_course_home_improvements=True) - def create_user(self, verification_status=None): - """ Create a new User instance. - - Arguments: - verification_status (str): User's verification status. If this value is set an instance of - SoftwareSecurePhotoVerification will be created for the user with the specified status. - """ - user = UserFactory() - - if verification_status is not None: - SoftwareSecurePhotoVerificationFactory.create(user=user, status=verification_status) - - return user - def enable_course_certificates(self, course): """ Enable course certificate configuration """ course.certificates = { @@ -74,7 +62,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_course_info_feature_flag(self): SelfPacedConfiguration(enable_course_home_improvements=False).save() course = create_course_run() - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) self.client.login(username=user.username, password=TEST_PASSWORD) @@ -144,7 +132,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): @ddt.unpack def test_enabled_block_types(self, course_kwargs, user_kwargs, expected_blocks): course = create_course_run(**course_kwargs) - user = self.create_user(**user_kwargs) + user = create_user(**user_kwargs) CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) self.assert_block_types(course, user, expected_blocks) @@ -160,12 +148,12 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): @ddt.unpack def test_enabled_block_types_without_enrollment(self, course_kwargs, expected_blocks): course = create_course_run(**course_kwargs) - user = self.create_user() + user = create_user() self.assert_block_types(course, user, expected_blocks) def test_enabled_block_types_with_non_upgradeable_course_run(self): course = create_course_run(days_till_start=-10, days_till_verification_deadline=None) - user = self.create_user() + user = create_user() CourseMode.objects.get(course_id=course.id, mode_slug=CourseMode.VERIFIED).delete() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.AUDIT) self.assert_block_types(course, user, (TodaysDate, CourseEndDate)) @@ -177,7 +165,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): """ with freeze_time('2015-01-02'): course = create_course_run() - user = self.create_user() + user = create_user() block = TodaysDate(course, user) self.assertTrue(block.is_enabled) self.assertEqual(block.date, datetime.now(utc)) @@ -191,7 +179,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_todays_date_no_timezone(self, url_name): with freeze_time('2015-01-02'): course = create_course_run() - user = self.create_user() + user = create_user() self.client.login(username=user.username, password=TEST_PASSWORD) html_elements = [ @@ -216,7 +204,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_todays_date_timezone(self, url_name): with freeze_time('2015-01-02'): course = create_course_run() - user = self.create_user() + user = create_user() self.client.login(username=user.username, password=TEST_PASSWORD) set_user_preference(user, 'time_zone', 'America/Los_Angeles') url = reverse(url_name, args=(course.id,)) @@ -237,7 +225,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ## Tests Course Start Date def test_course_start_date(self): course = create_course_run() - user = self.create_user() + user = create_user() block = CourseStartDate(course, user) self.assertEqual(block.date, course.start) @@ -249,7 +237,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_start_date_render(self, url_name): with freeze_time('2015-01-02'): course = create_course_run() - user = self.create_user() + user = create_user() self.client.login(username=user.username, password=TEST_PASSWORD) url = reverse(url_name, args=(course.id,)) response = self.client.get(url, follow=True) @@ -268,7 +256,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_start_date_render_time_zone(self, url_name): with freeze_time('2015-01-02'): course = create_course_run() - user = self.create_user() + user = create_user() self.client.login(username=user.username, password=TEST_PASSWORD) set_user_preference(user, 'time_zone', 'America/Los_Angeles') url = reverse(url_name, args=(course.id,)) @@ -284,7 +272,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ## Tests Course End Date Block def test_course_end_date_for_certificate_eligible_mode(self): course = create_course_run(days_till_start=-1) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = CourseEndDate(course, user) self.assertEqual( @@ -294,7 +282,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_course_end_date_for_non_certificate_eligible_mode(self): course = create_course_run(days_till_start=-1) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.AUDIT) block = CourseEndDate(course, user) self.assertEqual( @@ -305,7 +293,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_course_end_date_after_course(self): course = create_course_run(days_till_start=-2, days_till_end=-1) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = CourseEndDate(course, user) self.assertEqual( @@ -319,7 +307,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): sku = 'TESTSKU' configuration = CommerceConfiguration.objects.create(checkout_on_ecommerce_service=True) course = create_course_run() - user = self.create_user() + user = create_user() course_mode = CourseMode.objects.get(course_id=course.id, mode_slug=CourseMode.VERIFIED) course_mode.sku = sku course_mode.save() @@ -332,7 +320,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): @waffle.testutils.override_switch('certificates.auto_certificate_generation', True) def test_no_certificate_available_date(self): course = create_course_run(days_till_start=-1) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.AUDIT) block = CertificateAvailableDate(course, user) self.assertEqual(block.date, None) @@ -342,7 +330,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): @waffle.testutils.override_switch('certificates.auto_certificate_generation', True) def test_no_certificate_available_date_for_self_paced(self): course = create_self_paced_course_run() - verified_user = self.create_user() + verified_user = create_user() CourseEnrollmentFactory(course_id=course.id, user=verified_user, mode=CourseMode.VERIFIED) course.certificate_available_date = datetime.now(utc) + timedelta(days=7) course.save() @@ -356,7 +344,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): if the course only has audit mode. """ course = create_course_run() - audit_user = self.create_user() + audit_user = create_user() # Enroll learner in the audit mode and verify the course only has 1 mode (audit) CourseEnrollmentFactory(course_id=course.id, user=audit_user, mode=CourseMode.AUDIT) @@ -376,9 +364,9 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): @waffle.testutils.override_switch('certificates.auto_certificate_generation', True) def test_certificate_available_date_defined(self): course = create_course_run() - audit_user = self.create_user() + audit_user = create_user() CourseEnrollmentFactory(course_id=course.id, user=audit_user, mode=CourseMode.AUDIT) - verified_user = self.create_user() + verified_user = create_user() CourseEnrollmentFactory(course_id=course.id, user=verified_user, mode=CourseMode.VERIFIED) course.certificate_available_date = datetime.now(utc) + timedelta(days=7) self.enable_course_certificates(course) @@ -391,14 +379,14 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ## VerificationDeadlineDate def test_no_verification_deadline(self): course = create_course_run(days_till_start=-1, days_till_verification_deadline=None) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = VerificationDeadlineDate(course, user) self.assertFalse(block.is_enabled) def test_no_verified_enrollment(self): course = create_course_run(days_till_start=-1) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.AUDIT) block = VerificationDeadlineDate(course, user) self.assertFalse(block.is_enabled) @@ -406,7 +394,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_verification_deadline_date_upcoming(self): with freeze_time('2015-01-02'): course = create_course_run(days_till_start=-1) - user = self.create_user() + user = create_user() CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = VerificationDeadlineDate(course, user) @@ -423,7 +411,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_verification_deadline_date_retry(self): with freeze_time('2015-01-02'): course = create_course_run(days_till_start=-1) - user = self.create_user(verification_status='denied') + user = create_user(verification_status='denied') CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = VerificationDeadlineDate(course, user) @@ -440,7 +428,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_verification_deadline_date_denied(self): with freeze_time('2015-01-02'): course = create_course_run(days_till_start=-10, days_till_verification_deadline=-1) - user = self.create_user(verification_status='denied') + user = create_user(verification_status='denied') CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = VerificationDeadlineDate(course, user) @@ -462,13 +450,104 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): def test_render_date_string_past(self, delta, expected_date_string): with freeze_time('2015-01-02'): course = create_course_run(days_till_start=-10, days_till_verification_deadline=delta) - user = self.create_user(verification_status='denied') + user = create_user(verification_status='denied') CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) block = VerificationDeadlineDate(course, user) self.assertEqual(block.relative_datestring, expected_date_string) +@attr(shard=1) +@ddt.ddt +class TestDateAlerts(SharedModuleStoreTestCase): + """ + Unit tests for date alerts. + """ + def setUp(self): + super(TestDateAlerts, self).setUp() + with freeze_time('2017-07-01 09:00:00'): + self.course = create_course_run(days_till_start=0) + self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, mode=CourseMode.AUDIT) + self.request = RequestFactory().request() + self.request.session = {} + self.request.user = self.enrollment.user + MessageMiddleware().process_request(self.request) + + @ddt.data( + ['2017-01-01 09:00:00', u'in 6 months on <span class="date localized-datetime" data-format="shortDate"'], + ['2017-06-17 09:00:00', u'in 2 weeks on <span class="date localized-datetime" data-format="shortDate"'], + ['2017-06-30 10:00:00', u'in 1 day at <span class="date localized-datetime" data-format="shortTime"'], + ['2017-07-01 08:00:00', u'in 1 hour at <span class="date localized-datetime" data-format="shortTime"'], + ['2017-07-01 08:55:00', u'in 5 minutes at <span class="date localized-datetime" data-format="shortTime"'], + ['2017-07-01 09:00:00', None], + ['2017-08-01 09:00:00', None], + ) + @ddt.unpack + def test_start_date_alert(self, current_time, expected_message_html): + """ + Verify that course start date alerts are registered. + """ + with freeze_time(current_time): + block = CourseStartDate(self.course, self.request.user) + block.register_alerts(self.request, self.course) + messages = list(CourseHomeMessages.user_messages(self.request)) + if expected_message_html: + self.assertEqual(len(messages), 1) + self.assertIn(expected_message_html, messages[0].message_html) + else: + self.assertEqual(len(messages), 0) + + @ddt.data( + ['2017-06-30 09:00:00', None], + ['2017-07-01 09:00:00', u'in 2 weeks on <span class="date localized-datetime" data-format="shortDate"'], + ['2017-07-14 10:00:00', u'in 1 day at <span class="date localized-datetime" data-format="shortTime"'], + ['2017-07-15 08:00:00', u'in 1 hour at <span class="date localized-datetime" data-format="shortTime"'], + ['2017-07-15 08:55:00', u'in 5 minutes at <span class="date localized-datetime" data-format="shortTime"'], + ['2017-07-15 09:00:00', None], + ['2017-08-15 09:00:00', None], + ) + @ddt.unpack + def test_end_date_alert(self, current_time, expected_message_html): + """ + Verify that course end date alerts are registered. + """ + with freeze_time(current_time): + block = CourseEndDate(self.course, self.request.user) + block.register_alerts(self.request, self.course) + messages = list(CourseHomeMessages.user_messages(self.request)) + if expected_message_html: + self.assertEqual(len(messages), 1) + self.assertIn(expected_message_html, messages[0].message_html) + else: + self.assertEqual(len(messages), 0) + + @ddt.data( + ['2017-06-20 09:00:00', None], + ['2017-06-21 09:00:00', u'Don't forget, you have 2 weeks left to upgrade to a Verified Certificate.'], + ['2017-07-04 10:00:00', u'Don't forget, you have 1 day left to upgrade to a Verified Certificate.'], + ['2017-07-05 08:00:00', u'Don't forget, you have 1 hour left to upgrade to a Verified Certificate.'], + ['2017-07-05 08:55:00', u'Don't forget, you have 5 minutes left to upgrade to a Verified Certificate.'], + ['2017-07-05 09:00:00', None], + ['2017-08-05 09:00:00', None], + ) + @ddt.unpack + @override_waffle_flag(UPGRADE_DEADLINE_MESSAGE, active=True) + def test_verified_upgrade_deadline_alert(self, current_time, expected_message_html): + """ + Verify the verified upgrade deadline alerts. + """ + with freeze_time(current_time): + block = VerifiedUpgradeDeadlineDate(self.course, self.request.user) + block.register_alerts(self.request, self.course) + messages = list(CourseHomeMessages.user_messages(self.request)) + if expected_message_html: + self.assertEqual(len(messages), 1) + self.assertIn(expected_message_html, messages[0].message_html) + else: + self.assertEqual(len(messages), 0) + + + @attr(shard=1) class TestScheduleOverrides(SharedModuleStoreTestCase): @@ -560,6 +639,21 @@ class TestScheduleOverrides(SharedModuleStoreTestCase): self.assertEqual(block.date, expected) +def create_user(verification_status=None): + """ Create a new User instance. + + Arguments: + verification_status (str): User's verification status. If this value is set an instance of + SoftwareSecurePhotoVerification will be created for the user with the specified status. + """ + user = UserFactory() + + if verification_status is not None: + SoftwareSecurePhotoVerificationFactory.create(user=user, status=verification_status) + + return user + + def create_course_run( days_till_start=1, days_till_end=14, days_till_upgrade_deadline=4, days_till_verification_deadline=14, ): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 1b772ae3efc0f2247e05fbf72adaf7de808ed655..a200dfc22df65fb5e04730972d25ae04f923b52f 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -213,8 +213,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): NUM_PROBLEMS = 20 @ddt.data( - (ModuleStoreEnum.Type.mongo, 10, 145), - (ModuleStoreEnum.Type.split, 4, 145), + (ModuleStoreEnum.Type.mongo, 10, 147), + (ModuleStoreEnum.Type.split, 4, 147), ) @ddt.unpack def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): diff --git a/lms/envs/common.py b/lms/envs/common.py index b9a8ab1af1e5a0c73b7e01a8db9df5d0a7b1fbf3..6b561a591f9ff82c33788d9a447180cb6b1e2e5a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -431,6 +431,9 @@ XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5 RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5 +# Deadline message configurations +COURSE_MESSAGE_ALERT_DURATION_IN_DAYS = 14 + ############################# SET PATH INFORMATION ############################# PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/lms REPO_ROOT = PROJECT_ROOT.dirname() @@ -2589,6 +2592,7 @@ MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60 TIME_ZONE_DISPLAYED_FOR_DEADLINES = 'UTC' + ########################## VIDEO IMAGE STORAGE ############################ VIDEO_IMAGE_SETTINGS = dict( diff --git a/lms/static/sass/_build-base-v1.scss b/lms/static/sass/_build-base-v1.scss index 0c63cb6c344c7cd17ff7569439458517bccd79ff..e4171d400aed64d43ec115c24fa638af2afcb959 100644 --- a/lms/static/sass/_build-base-v1.scss +++ b/lms/static/sass/_build-base-v1.scss @@ -7,3 +7,7 @@ @import 'base/variables'; @import 'base/mixins'; @import 'base/theme'; + +// Pattern Library shims +@import 'edx-pattern-library-shims/base/variables'; +@import 'edx-pattern-library-shims/buttons'; diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss index ef5dc816ad0642cd7daefb8d96495747122a08f2..be8a9bb4e76aabf9b90406f5e1ecd0768602ac47 100644 --- a/lms/static/sass/_build-lms-v2.scss +++ b/lms/static/sass/_build-lms-v2.scss @@ -21,6 +21,7 @@ // Elements @import 'notifications'; @import 'elements/controls'; +@import 'elements-v2/buttons'; @import 'elements-v2/pagination'; // Features diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index 60bbeb52bf8ef10ace7357381efc9fbd24cacc12..d127a9584e0ecb743886f40694205d317ce15005 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -1,13 +1,3 @@ -// Upgrade button -$btn-upgrade-border-color: $uxpl-green-base !default; -$btn-upgrade-background: $uxpl-green-base !default; -$btn-upgrade-color: #fcfcfc !default; -$btn-upgrade-focus-color: $btn-upgrade-color !default; -$btn-upgrade-focus-border-color: rgb(0, 155, 0) !default; -$btn-upgrade-focus-background: rgb(0, 155, 0) !default; -$btn-upgrade-active-border-color: $uxpl-green-base !default; -$btn-upgrade-active-background: $uxpl-green-base !default; - //// Notifications // Upgrade @@ -142,31 +132,6 @@ div.info-wrapper { @include margin(0, 0, 0, auto); padding: $baseline/2 $baseline; } - - .btn-upgrade { - @extend %btn-shims; - - border-color: $btn-upgrade-border-color; - background: $btn-upgrade-background; - color: $btn-upgrade-color; - // STATE: hover and focus - &:hover, - &.is-hovered, - &:focus, - &.is-focused { - border-color: $btn-upgrade-focus-border-color; - background-color: $btn-upgrade-focus-background; - color: $btn-upgrade-focus-color; - } - - // STATE: is disabled - &:disabled, - &.is-disabled { - border-color: $btn-disabled-border-color; - background: $btn-brand-disabled-background; - color: $btn-upgrade-color; - } - } } } diff --git a/lms/static/sass/elements-v2/_buttons.scss b/lms/static/sass/elements-v2/_buttons.scss new file mode 100644 index 0000000000000000000000000000000000000000..00541fc25d0d144c76a9a5f4fd8cd6a494ed5e37 --- /dev/null +++ b/lms/static/sass/elements-v2/_buttons.scss @@ -0,0 +1,42 @@ +// ---------------------------- +// #UPGRADE +// ---------------------------- +$upgrade-color: #009b00 !default; +$upgrade-dark-color: #008100 !default; + +.btn-upgrade { + @extend %btn; + + border-color: $upgrade-color; + background: $upgrade-color; + color: palette(primary, x-back); + text-decoration: none; + + // STATE: hover and focus + &:hover, + &.is-hovered, + &:focus, + &.is-focused { + border-color: $upgrade-dark-color; + background: $upgrade-dark-color; + text-decoration: none; + } + + // STATE: is pressed or active + &:active, + &.is-pressed, + &.is-active { + border-color: $upgrade-dark-color; + background: $upgrade-dark-color; + text-decoration: none; + } + + // STATE: is disabled + &:disabled, + &.is-disabled { + border-color: $btn-disabled-border-color; + background: $btn-disabled-background-color; + color: $btn-disabled-text-color; + text-decoration: none; + } +} diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index fff4702801d65c1ee49995ce0b3c4caaf6126e61..5a9fe4120eb8a151e1f288e6c34d98fd4fb2480b 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -16,6 +16,7 @@ .message-content { @include margin(0, 0, $baseline, $baseline); + position: relative; border: 1px solid $lms-border-color; padding: $baseline; @@ -60,15 +61,17 @@ .message-header { font-weight: $font-semibold; margin-bottom: $baseline/2; - width: calc(100% - 40px) + width: calc(100% - 40px); } - a { + a:not(.btn) { font-weight: $font-semibold; text-decoration: underline; } + .dismiss { @include right($baseline/4); + top: $baseline/4; position: absolute; cursor: pointer; @@ -90,6 +93,7 @@ &.dismissible { @include right($baseline/4); + position: absolute; top: $baseline/2; font-size: font-size(small); @@ -103,6 +107,12 @@ } } } + + .message-actions { + display: flex; + margin-top: $baseline/2; + justify-content: flex-end; + } } // Welcome message / Latest Update message diff --git a/lms/static/sass/features/_course-sock.scss b/lms/static/sass/features/_course-sock.scss index 9b59dc694c429fc8faa407802e8c6328c2e33434..d08327c792a8e80404612496963503ec0b6fb95b 100644 --- a/lms/static/sass/features/_course-sock.scss +++ b/lms/static/sass/features/_course-sock.scss @@ -111,10 +111,6 @@ .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) { & { @@ -142,11 +138,6 @@ top: auto; } } - - &:hover { - background-color: $success-color-hover; - border-color: $success-color-hover; - } } } } diff --git a/lms/static/sass/features/_course-upgrade-message.scss b/lms/static/sass/features/_course-upgrade-message.scss index e967c534c0ccbc8c9fab6f16c8c5c8bd91a23631..0a15a8d5631fdab93185ee74504e02b0f409218e 100644 --- a/lms/static/sass/features/_course-upgrade-message.scss +++ b/lms/static/sass/features/_course-upgrade-message.scss @@ -70,13 +70,6 @@ $upgrade-message-background-color: $blue-d1; color: $white; } - // Upgrade Button - .btn-upgrade { - @extend %btn-primary-green; - - background: $uxpl-green-base; - } - // Cert image .vc-hero { @include float(right); diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index 8c9980f5788f219e24bc56abc56c9b4d07c1af83..a7c48a506c0d65b92ef3a0912e26cf0197c13177 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -28,8 +28,12 @@ SHOW_REVIEWS_TOOL_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_reviews_t # Waffle flag to enable the setting of course goals. ENABLE_COURSE_GOALS = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_course_goals') +# Waffle flag to control the display of the hero SHOW_UPGRADE_MSG_ON_COURSE_HOME = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_upgrade_msg_on_course_home') +# Waffle flag to control the display of the upgrade deadline message +UPGRADE_DEADLINE_MESSAGE = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'upgrade_deadline_message') + # Waffle flag to switch between the 'welcome message' and 'latest update' on the course home page. # Important Admin Note: This is meant to be configured using waffle_utils course # override only. Either do not create the actual waffle flag, or be sure to unset the 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 7216dae9d002b7661d453e3ffa3da7435523aa5f..7f5cfd550fd52a67ac41498db3891e0402e2f272 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 @@ -82,7 +82,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV </ul> <div class="vc-cta vc-fade vc-polite-only"> - <a class="btn-upgrade" href="${ upgrade_url }">${_("Upgrade ({price})").format(price='$' + str(upgrade_price))}</a> + <a class="btn-upgrade" href="${ upgrade_url }">${_("Upgrade ({price})").format(price=upgrade_price)}</a> </div> </div> </div> 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 index b569f0bf08b7d30830ecc6980ac7a201bbd3e2d0..7deac01524120060e32ae59e829befb6cca3af29 100644 --- a/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html @@ -55,9 +55,9 @@ from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG </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)}) + <a href="${upgrade_url}"> + <button type="button" class="btn btn-upgrade stuck-top focusable action-upgrade-certificate"> + Upgrade (${HTML(course_price)}) </button> </a> </div> 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 a6a118d10e3f8ccee099f7abff5247717b52e572..7a2ecfcca7f9733c503b4c6fa2223bcdffd6d4b3 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -173,7 +173,7 @@ class TestCourseHomePage(CourseHomePageTestCase): course_home_url(self.course) # Fetch the view and verify the query counts - with self.assertNumQueries(44, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): + with self.assertNumQueries(45, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with check_mongo_calls(4): url = course_home_url(self.course) self.client.get(url) @@ -477,11 +477,9 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): response = self.client.get(self.url) self.assertIn('vc-message', response.content) url = EcommerceService().get_checkout_page_url(self.verified_mode.sku) - expected = '<a class="btn-upgrade" href="{url}">Upgrade (${price})</a>'.format( - url=url, - price=self.verified_mode.min_price - ) - self.assertIn(expected, response.content) + self.assertIn('<a class="btn-upgrade"', response.content) + self.assertIn(url, response.content) + self.assertIn('Upgrade (${price})</a>'.format(price=self.verified_mode.min_price), response.content) def test_no_upgrade_message_if_logged_out(self): self.client.logout() diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py index 026eeb91a75cce5fab507c3a28b7a7e25b6f21f6..7c70b8ca99ad1677d531c4b1d97897aa780caf56 100644 --- a/openedx/features/course_experience/views/course_home.py +++ b/openedx/features/course_experience/views/course_home.py @@ -10,6 +10,7 @@ from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie from commerce.utils import EcommerceService +from course_modes.models import get_cosmetic_verified_display_price from courseware.access import has_access from courseware.courses import ( can_self_enroll_in_course, @@ -165,15 +166,8 @@ class CourseHomeFragmentView(EdxFragmentView): # TODO Add switch to control deployment if SHOW_UPGRADE_MSG_ON_COURSE_HOME.is_enabled(course_key) and enrollment and enrollment.upgrade_deadline: - verified_mode = enrollment.verified_mode - if verified_mode: - upgrade_price = verified_mode.min_price - - ecommerce_service = EcommerceService() - if ecommerce_service.is_enabled(request.user): - upgrade_url = ecommerce_service.get_checkout_page_url(verified_mode.sku) - else: - upgrade_url = reverse('verify_student_upgrade_and_verify', args=(course_key,)) + upgrade_url = EcommerceService().upgrade_url(request.user, course_key) + upgrade_price = get_cosmetic_verified_display_price(course) # Render the course home fragment context = { diff --git a/openedx/features/course_experience/views/course_home_messages.py b/openedx/features/course_experience/views/course_home_messages.py index a851695f21029f4a8192ff32a33617217bb34702..3f35c8e68f83ccb1934a69eae2fc166890783e5c 100644 --- a/openedx/features/course_experience/views/course_home_messages.py +++ b/openedx/features/course_experience/views/course_home_messages.py @@ -17,7 +17,7 @@ from rest_framework.reverse import reverse from web_fragments.fragment import Fragment from course_modes.models import CourseMode -from courseware.courses import get_course_with_access +from courseware.courses import get_course_date_blocks, get_course_with_access from lms.djangoapps.course_goals.api import get_course_goal from lms.djangoapps.course_goals.models import GOAL_KEY_CHOICES from openedx.core.djangoapps.plugin_api.views import EdxFragmentView @@ -64,7 +64,15 @@ class CourseHomeMessageFragmentView(EdxFragmentView): } # Register the course home messages to be loaded on the page - _register_course_home_messages(request, course_id, user_access, course_start_data) + _register_course_home_messages(request, course, user_access, course_start_data) + + # Register course date alerts + for course_date_block in get_course_date_blocks(course, request.user): + course_date_block.register_alerts(request, course) + + # Register a course goal message, if appropriate + if _should_show_course_goal_message(request, course, user_access): + _register_course_goal_message(request, course) # Grab the relevant messages course_home_messages = list(CourseHomeMessages.user_messages(request)) @@ -73,7 +81,7 @@ class CourseHomeMessageFragmentView(EdxFragmentView): goal_api_url = reverse('course_goals_api:v0:course_goal-list', request=request) # Grab the logo - image_src = "course_experience/images/home_message_author.png" + image_src = 'course_experience/images/home_message_author.png' context = { 'course_home_messages': course_home_messages, @@ -87,24 +95,22 @@ class CourseHomeMessageFragmentView(EdxFragmentView): return Fragment(html) -def _register_course_home_messages(request, course_id, user_access, course_start_data): +def _register_course_home_messages(request, course, user_access, course_start_data): """ Register messages to be shown in the course home content page. """ - course_key = CourseKey.from_string(course_id) - course = get_course_with_access(request.user, 'load', course_key) if user_access['is_anonymous']: CourseHomeMessages.register_info_message( request, Text(_( - " {sign_in_link} or {register_link} and then enroll in this course." + '{sign_in_link} or {register_link} and then enroll in this course.' )).format( - sign_in_link=HTML("<a href='/login?next={current_url}'>{sign_in_label}</a>").format( - sign_in_label=_("Sign in"), + sign_in_link=HTML('<a href="/login?next={current_url}">{sign_in_label}</a>').format( + sign_in_label=_('Sign in'), current_url=urlquote_plus(request.path), ), - register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").format( - register_label=_("register"), + register_link=HTML('<a href="/register?next={current_url}">{register_label}</a>').format( + register_label=_('register'), current_url=urlquote_plus(request.path), ) ), @@ -114,7 +120,7 @@ def _register_course_home_messages(request, course_id, user_access, course_start CourseHomeMessages.register_info_message( request, Text(_( - "{open_enroll_link} Enroll now{close_enroll_link} to access the full course." + '{open_enroll_link}Enroll now{close_enroll_link} to access the full course.' )).format( open_enroll_link='', close_enroll_link='' @@ -123,81 +129,97 @@ def _register_course_home_messages(request, course_id, user_access, course_start course_display_name=course.display_name ) ) - if user_access['is_enrolled'] and not course_start_data['already_started']: - CourseHomeMessages.register_info_message( - request, - Text(_( - "Don't forget to add a calendar reminder!" - )), - title=Text(_("Course starts in {days_until_start_string} on {course_start_date}.")).format( - days_until_start_string=course_start_data['days_until_start_string'], - course_start_date=course_start_data['course_start_date'] - ) - ) - # Only show the set course goal message for enrolled, unverified - # users that have not yet set a goal in a course that allows for - # verified statuses. - has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course.id))) - is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key) - user_goal = get_course_goal(auth.get_user(request), course_key) if not request.user.is_anonymous() else None - if user_access['is_enrolled'] and has_verified_mode and not is_already_verified and not user_goal \ - and ENABLE_COURSE_GOALS.is_enabled(course_key) and settings.FEATURES.get('ENABLE_COURSE_GOALS'): - goal_choices_html = Text(_( - 'To start, set a course goal by selecting the option below that best describes ' - 'your learning plan. {goal_options_container}' - )).format( - goal_options_container=HTML('<div class="row goal-options-container">') - ) - # Add the dismissible option for users that are unsure of their goal - goal_choices_html += Text( - '{initial_tag}{choice}{closing_tag}' +def _should_show_course_goal_message(request, course, user_access): + """ + Returns true if the current learner should be shown a course goal message. + """ + course_key = course.id + + # Don't show a message if course goals has not been enabled + if not ENABLE_COURSE_GOALS.is_enabled(course_key) or not settings.FEATURES.get('ENABLE_COURSE_GOALS'): + return False + + # Don't show a message if the user is not enrolled + if not user_access['is_enrolled']: + return False + + # Don't show a message if the learner has already specified a goal + if get_course_goal(auth.get_user(request), course_key): + return False + + # Don't show a message if the course does not have a verified mode + if not CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course_key))): + return False + + # Don't show a message if the learner has already verified + if CourseEnrollment.is_enrolled_as_verified(request.user, course_key): + return False + + return True + + +def _register_course_goal_message(request, course): + """ + Register a message to let a learner specify a course goal. + """ + goal_choices_html = Text(_( + 'To start, set a course goal by selecting the option below that best describes ' + 'your learning plan. {goal_options_container}' + )).format( + goal_options_container=HTML('<div class="row goal-options-container">') + ) + + # Add the dismissible option for users that are unsure of their goal + goal_choices_html += Text( + '{initial_tag}{choice}{closing_tag}' + ).format( + initial_tag=HTML( + '<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option dismissible" ' + 'data-choice="{goal_key}">' + ).format( + goal_key=GOAL_KEY_CHOICES.unsure, + aria_label_choice=Text(_("Set goal to: {choice}")).format( + choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure] + ), + ), + choice=Text(_('{choice}')).format( + choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure], + ), + closing_tag=HTML('</div>'), + ) + + # Add the option to set a goal to earn a certificate, + # complete the course or explore the course + goal_options = [ + GOAL_KEY_CHOICES.certify, + GOAL_KEY_CHOICES.complete, + GOAL_KEY_CHOICES.explore + ] + for goal_key in goal_options: + goal_text = GOAL_KEY_CHOICES[goal_key] + goal_choices_html += HTML( + '{initial_tag}{goal_text}{closing_tag}' ).format( initial_tag=HTML( - '<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option dismissible" ' + '<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option {col_sel} btn" ' 'data-choice="{goal_key}">' ).format( - goal_key=GOAL_KEY_CHOICES.unsure, - aria_label_choice=Text(_("Set goal to: {choice}")).format( - choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure] + goal_key=goal_key, + aria_label_choice=Text(_("Set goal to: {goal_text}")).format( + goal_text=Text(_(goal_text)) ), + col_sel='col-' + str(int(math.floor(12 / len(goal_options)))) ), - choice=Text(_('{choice}')).format( - choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure], - ), - closing_tag=HTML('</div>'), + goal_text=goal_text, + closing_tag=HTML('</div>') ) - # Add the option to set a goal to earn a certificate, - # complete the course or explore the course - goal_options = [GOAL_KEY_CHOICES.certify, GOAL_KEY_CHOICES.complete, GOAL_KEY_CHOICES.explore] - for goal_key in goal_options: - goal_text = GOAL_KEY_CHOICES[goal_key] - goal_choices_html += HTML( - '{initial_tag}{goal_text}{closing_tag}' - ).format( - initial_tag=HTML( - '<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option {col_sel} btn" ' - 'data-choice="{goal_key}">' - ).format( - goal_key=goal_key, - aria_label_choice=Text(_("Set goal to: {goal_text}")).format( - goal_text=Text(_(goal_text)) - ), - col_sel='col-' + str(int(math.floor(12 / len(goal_options)))) - ), - goal_text=goal_text, - closing_tag=HTML('</div>') - ) - - CourseHomeMessages.register_info_message( - request, - HTML('{goal_choices_html}{closing_tag}').format( - goal_choices_html=goal_choices_html, - closing_tag=HTML('</div>') - ), - title=Text(_('Welcome to {course_display_name}')).format( - course_display_name=course.display_name - ) + CourseHomeMessages.register_info_message( + request, + goal_choices_html, + title=Text(_('Welcome to {course_display_name}')).format( + course_display_name=course.display_name ) + ) diff --git a/openedx/features/course_experience/views/course_sock.py b/openedx/features/course_experience/views/course_sock.py index 20f88e06ca6c2224d8570b1c5a81a9ed1b107fd3..9c27c77183c32f0e9d219bda6bfadd4f3549d1a7 100644 --- a/openedx/features/course_experience/views/course_sock.py +++ b/openedx/features/course_experience/views/course_sock.py @@ -6,10 +6,11 @@ from django.utils.translation import get_language from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment -from student.models import CourseEnrollment +from commerce.utils import EcommerceService from course_modes.models import CourseMode, get_cosmetic_verified_display_price from courseware.date_summary import VerifiedUpgradeDeadlineDate from openedx.core.djangoapps.plugin_api.views import EdxFragmentView +from student.models import CourseEnrollment class CourseSockFragmentView(EdxFragmentView): @@ -44,13 +45,15 @@ class CourseSockFragmentView(EdxFragmentView): not deadline_has_passed and get_language() == 'en' ) - # Get the price of the course and format correctly + # Get information about the upgrade course_price = get_cosmetic_verified_display_price(course) + upgrade_url = EcommerceService().upgrade_url(request.user, course_key) context = { 'show_course_sock': show_course_sock, 'course_price': course_price, - 'course_id': course.id + 'course_id': course.id, + 'upgrade_url': upgrade_url, } return context