diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 10a6c641dd..1894711f00 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -412,19 +412,24 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest url = reverse('create_mode', args=[six.text_type(self.course.id)]) response = self.client.get(url, parameters) + CourseEnrollmentFactory( + is_active=True, + course_id=self.course.id, + user=self.user + ) + response = self.client.get( reverse('course_modes_choose', args=[six.text_type(self.course.id)]), follow=False, ) - bannerText = u'''
- 15% off your first upgrade. Discount automatically applied.
''' + banner = u'''
''' button = u'''''' - self.assertContains(response, bannerText, html=True) + self.assertContains(response, banner) self.assertContains(response, button, html=True) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index baf6a6e0c8..23ed9e204c 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -33,6 +33,11 @@ color: #23419F; } + a { + color: #00496f; + text-decoration: underline; + font-weight: bold; + } } // Course call to action message diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 0053273534..9624b84803 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -2261,11 +2261,19 @@ font-size: 16px; border-radius: 7px; padding: 20px; + line-height: 1.5; .first-purchase-offer-banner-bold { font-weight: bold; color: #23419f; } + + a { + color: #00496f; + text-decoration: underline !important; + font-weight: bold; + border-bottom: none; + } } } 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 ad0f0083ce..90de4d8c70 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -25,6 +25,7 @@ from experiments.models import ExperimentData from lms.djangoapps.commerce.models import CommerceConfiguration from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.course_goals.api import add_course_goal, remove_course_goal +from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link from lms.djangoapps.courseware.tests.factories import ( BetaTesterFactory, GlobalStaffFactory, @@ -34,6 +35,8 @@ from lms.djangoapps.courseware.tests.factories import ( StaffFactory ) from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory +from openedx.features.discounts.applicability import get_discount_expiration_date +from openedx.features.discounts.utils import format_strikeout_price from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.dark_lang.models import DarkLangConfig from openedx.core.djangoapps.django_comment_common.models import ( @@ -217,7 +220,7 @@ class TestCourseHomePage(CourseHomePageTestCase): # Fetch the view and verify the query counts # TODO: decrease query count as part of REVO-28 - with self.assertNumQueries(95, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): + with self.assertNumQueries(97, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with check_mongo_calls(4): url = course_home_url(self.course) self.client.get(url) @@ -432,11 +435,19 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): self.client.login(username=user.username, password=self.TEST_PASSWORD) url = course_home_url(self.course) response = self.client.get(url) + discount_expiration_date = get_discount_expiration_date(user, self.course).strftime(u'%B %d') + upgrade_link = verified_upgrade_deadline_link(user=user, course=self.course) bannerText = u'''
- - {}% off your first upgrade. - Discount automatically applied. -
'''.format(percentage) + + Upgrade by {discount_expiration_date} and save {percentage}% [{strikeout_price}] +
Discount will be automatically applied at checkout. Upgrade Now +
'''.format( + discount_expiration_date=discount_expiration_date, + percentage=percentage, + strikeout_price=HTML(format_strikeout_price(user, self.course, check_for_discount=False)[0]), + upgrade_link=upgrade_link + ) + if applicability: self.assertContains(response, bannerText, html=True) else: diff --git a/openedx/features/course_experience/utils.py b/openedx/features/course_experience/utils.py index f3b2a4fbf0..5baa5946b3 100644 --- a/openedx/features/course_experience/utils.py +++ b/openedx/features/course_experience/utils.py @@ -11,9 +11,15 @@ from web_fragments.fragment import Fragment from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_blocks.utils import get_student_module_as_dict +from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link from openedx.core.djangolib.markup import HTML from openedx.core.lib.cache_utils import request_cached -from openedx.features.discounts.applicability import can_receive_discount, discount_percentage +from openedx.features.discounts.applicability import ( + can_receive_discount, + get_discount_expiration_date, + discount_percentage +) +from openedx.features.discounts.utils import format_strikeout_price from xmodule.modulestore.django import modulestore @@ -192,16 +198,27 @@ def get_resume_block(block): def get_first_purchase_offer_banner_fragment(user, course): - if user and course and can_receive_discount(user=user, course=course): - # Translator: xgettext:no-python-format - offer_message = _(u'{banner_open}{percentage}% off your first upgrade.{span_close}' - u' Discount automatically applied.{div_close}') - return Fragment(HTML(offer_message).format( - banner_open=HTML( - '
' - ), - percentage=discount_percentage(), - span_close=HTML(''), - div_close=HTML('
') - )) + if user and course: + discount_expiration_date = get_discount_expiration_date(user, course) + if (discount_expiration_date and + can_receive_discount(user=user, course=course, discount_expiration_date=discount_expiration_date)): + # Translator: xgettext:no-python-format + offer_message = _(u'{banner_open} Upgrade by {discount_expiration_date} and save {percentage}% ' + u'[{strikeout_price}]{span_close}{br}Discount will be automatically applied at checkout. ' + u'{a_open}Upgrade Now{a_close}{div_close}') + return Fragment(HTML(offer_message).format( + a_open=HTML(u'').format( + upgrade_link=verified_upgrade_deadline_link(user=user, course=course) + ), + a_close=HTML(''), + br=HTML('
'), + banner_open=HTML( + '
' + ), + discount_expiration_date=discount_expiration_date.strftime(u'%B %d'), + percentage=discount_percentage(), + span_close=HTML(''), + div_close=HTML('
'), + strikeout_price=HTML(format_strikeout_price(user, course, check_for_discount=False)[0]) + )) return None diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 425ed63cfa..310bf3a34b 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -10,9 +10,10 @@ not other discounts like coupons or enterprise/program offers configured in ecom """ from __future__ import absolute_import -from datetime import datetime +from datetime import datetime, timedelta from crum import get_current_request, impersonate +from django.utils import timezone import pytz from course_modes.models import CourseMode @@ -43,7 +44,40 @@ DISCOUNT_APPLICABILITY_FLAG = WaffleFlag( DISCOUNT_APPLICABILITY_HOLDBACK = 'first_purchase_discount_holdback' -def can_receive_discount(user, course): # pylint: disable=unused-argument +def get_discount_expiration_date(user, course): + """ + Returns the date when the discount expires for the user. + Returns none if the user is not enrolled. + """ + course_enrollment = CourseEnrollment.objects.filter( + user=user, + course=course.id, + mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES + ) + if len(course_enrollment) != 1: + return None + + enrollment = course_enrollment.first() + try: + # Content availability date is equivalent to max(enrollment date, course start date) + # for most people. Using the schedule date will provide flexibility to deal with + # more complex business rules in the future. + content_availability_date = enrollment.schedule.start + # We have anecdotally observed a case where the schedule.start was + # equal to the course start, but should have been equal to the enrollment start + # https://openedx.atlassian.net/browse/PROD-58 + # This section is meant to address that case + if enrollment.created and course.start: + if (content_availability_date.date() == course.start.date() and + course.start < enrollment.created < timezone.now()): + content_availability_date = enrollment.created + except CourseEnrollment.schedule.RelatedObjectDoesNotExist: + content_availability_date = max(enrollment.created, course.start) + + return content_availability_date + timedelta(weeks=1) + + +def can_receive_discount(user, course, discount_expiration_date=None): """ Check all the business logic about whether this combination of user and course can receive a discount. @@ -55,6 +89,16 @@ def can_receive_discount(user, course): # pylint: disable=unused-argument # TODO: Add additional conditions to return False here + # Check if discount has expired + if not discount_expiration_date: + discount_expiration_date = get_discount_expiration_date(user, course) + + if discount_expiration_date is None: + return False + + if discount_expiration_date < timezone.now(): + return False + # Course end date needs to be in the future if course.has_ended(): return False diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index 1876747231..258e2a1548 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -53,6 +53,12 @@ class TestApplicability(ModuleStoreTestCase): """ Ensure first purchase offer banner only displays for courses with a non-expired verified mode """ + CourseEnrollmentFactory( + is_active=True, + course_id=self.course.id, + user=self.user + ) + applicability = can_receive_discount(user=self.user, course=self.course) self.assertEqual(applicability, True) @@ -86,6 +92,12 @@ class TestApplicability(ModuleStoreTestCase): """ Ensure that only users who have not already purchased courses receive the discount. """ + CourseEnrollmentFactory( + is_active=True, + course_id=self.course.id, + user=self.user + ) + for mode in existing_enrollments: CourseEnrollmentFactory.create(mode=mode, user=self.user) @@ -102,6 +114,12 @@ class TestApplicability(ModuleStoreTestCase): """ Ensure that only users who have not already purchased courses receive the discount. """ + CourseEnrollmentFactory( + is_active=True, + course_id=self.course.id, + user=self.user + ) + if entitlement_mode is not None: CourseEntitlementFactory.create(mode=entitlement_mode, user=self.user) diff --git a/openedx/features/discounts/utils.py b/openedx/features/discounts/utils.py index 8c490bea7f..bdd6bda86d 100644 --- a/openedx/features/discounts/utils.py +++ b/openedx/features/discounts/utils.py @@ -9,7 +9,7 @@ from openedx.core.djangolib.markup import HTML from .applicability import can_receive_discount, discount_percentage -def format_strikeout_price(user, course, base_price=None): +def format_strikeout_price(user, course, base_price=None, check_for_discount=True): """ Return a formatted price, including a struck-out original price if a discount applies, and also whether a discount was applied, as the tuple (formatted_price, has_discount). @@ -19,7 +19,7 @@ def format_strikeout_price(user, course, base_price=None): original_price = format_course_price(base_price) - if can_receive_discount(user, course): + if not check_for_discount or can_receive_discount(user, course): discount_price = base_price * ((100.0 - discount_percentage()) / 100) if discount_price == int(discount_price): discount_price = format_course_price("{:0.0f}".format(discount_price))