From d2de618b20b875d69b3cd4c46240af7fe8732bfa Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 10 Jun 2019 13:52:11 -0400 Subject: [PATCH 1/6] Add strike-out price to LMS Course Home sidebar --- .../features/_course-upgrade-message.scss | 9 ++++++- .../course-home-fragment.html | 13 ++++++--- .../tests/views/test_course_home.py | 27 +++++++++++++++++++ .../course_experience/views/course_home.py | 12 +++++++-- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/lms/static/sass/features/_course-upgrade-message.scss b/lms/static/sass/features/_course-upgrade-message.scss index bcc0e45ee0..7ad6abdc68 100644 --- a/lms/static/sass/features/_course-upgrade-message.scss +++ b/lms/static/sass/features/_course-upgrade-message.scss @@ -30,9 +30,12 @@ Search for the courseware_verified_certificate_upsell promotion ID. } .section-upgrade .upgrade-container { + margin-top: 15px; +} + +.section-upgrade.no-discount .upgrade-container { float: right; text-align: center; - margin-top: 15px; } @media only screen and (max-width: 991px) and (min-width: 768px) { @@ -45,6 +48,10 @@ Search for the courseware_verified_certificate_upsell promotion ID. margin: 0.5em 0; } +.section.section-upgrade.discount p { + display: inline-block; +} + .section-upgrade .btn-brand.btn-upgrade { color: white !important; } 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 c90825a9dd..89b488e7ed 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 @@ -124,7 +124,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV % endif % if upgrade_url and upgrade_price: -
+ % endif 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 3e0a0a92a4..d81c058dee 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -53,6 +53,7 @@ from openedx.features.course_experience import ( SHOW_UPGRADE_MSG_ON_COURSE_HOME, UNIFIED_COURSE_TAB_FLAG ) +from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG from student.models import CourseEnrollment from student.tests.factories import UserFactory from util.date_utils import strftime_localized @@ -927,6 +928,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN) +@ddt.ddt class CourseHomeFragmentViewTests(ModuleStoreTestCase): """ Test Messages Displayed on the Course Home @@ -1001,3 +1003,28 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): def test_display_upgrade_message_if_audit_and_deadline_not_passed(self): CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) self.assert_upgrade_message_displayed() + + @ddt.data(True, False) + def test_upgrade_message_discount(self, has_discount): + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + + self.verified_mode.min_price = 100 + self.verified_mode.save() + + with FIRST_PURCHASE_OFFER_BANNER_DISPLAY.override(has_discount): + with DISCOUNT_APPLICABILITY_FLAG.override(has_discount): + response = self.client.get(self.url) + + self.assertIn('section-upgrade', response.content) + url = EcommerceService().get_checkout_page_url(self.verified_mode.sku) + self.assertIn('$100' in content + else: + assert u'$85' not in content + assert u'' not in content + assert u'Upgrade ($100)' in content diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py index 8b4796ed90..37d5ca27f0 100644 --- a/openedx/features/course_experience/views/course_home.py +++ b/openedx/features/course_experience/views/course_home.py @@ -14,7 +14,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment -from course_modes.models import get_cosmetic_verified_display_price +from course_modes.models import get_cosmetic_verified_display_price, get_course_prices, format_course_price from courseware.access import has_access from courseware.courses import can_self_enroll_in_course, get_course_info_section, get_course_with_access from lms.djangoapps.commerce.utils import EcommerceService @@ -32,6 +32,7 @@ from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_bann from openedx.features.course_duration_limits.access import generate_course_expired_fragment from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.utils import get_first_purchase_offer_banner_fragment +from openedx.features.discounts.applicability import discount_percentage from student.models import CourseEnrollment from util.views import ensure_valid_course_key from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE @@ -204,11 +205,17 @@ class CourseHomeFragmentView(EdxFragmentView): # Get info for upgrade messaging upgrade_price = None upgrade_url = None + upgrade_price_before_discount = None # TODO Add switch to control deployment if SHOW_UPGRADE_MSG_ON_COURSE_HOME.is_enabled(course_key) and enrollment and enrollment.upgrade_deadline: upgrade_url = EcommerceService().upgrade_url(request.user, course_key) - upgrade_price = get_cosmetic_verified_display_price(course) + if offer_banner_fragment: + upgrade_price_before_discount_num = get_course_prices(course, verified_only=True)[0] + upgrade_price_before_discount = format_course_price(upgrade_price_before_discount_num) + upgrade_price = format_course_price("{:0.2f}".format(upgrade_price_before_discount_num * ((100.0 - discount_percentage()) / 100))) + else: + upgrade_price = get_cosmetic_verified_display_price(course) # Render the course home fragment context = { @@ -236,6 +243,7 @@ class CourseHomeFragmentView(EdxFragmentView): 'uses_pattern_library': True, 'upgrade_price': upgrade_price, 'upgrade_url': upgrade_url, + 'upgrade_price_before_discount': upgrade_price_before_discount, } html = render_to_string('course_experience/course-home-fragment.html', context) return Fragment(html) From dc7daccfc8c20de6ee9e8552066cc7700fba5afe Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 10 Jun 2019 14:26:42 -0400 Subject: [PATCH 2/6] Add strike-out price in LMS courseware --- .../content_type_gating/partitions.py | 16 ++++++++-- .../access_denied_message.html | 7 ++++- .../content_type_gating/tests/test_access.py | 29 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/openedx/features/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py index b3e8c492da..4c30c66d13 100644 --- a/openedx/features/content_type_gating/partitions.py +++ b/openedx/features/content_type_gating/partitions.py @@ -17,9 +17,11 @@ from web_fragments.fragment import Fragment from course_modes.models import CourseMode from lms.djangoapps.commerce.utils import EcommerceService +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.mobile_utils import is_request_from_mobile_app from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, FULL_ACCESS, LIMITED_ACCESS from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.discounts.applicability import discount_percentage, can_receive_discount from xmodule.partitions.partitions import UserPartition, UserPartitionError LOG = logging.getLogger(__name__) @@ -76,7 +78,8 @@ class ContentTypeGatingPartition(UserPartition): """ def access_denied_fragment(self, block, user, user_group, allowed_groups): course_key = self._get_course_key_from_course_block(block) - modes = CourseMode.modes_for_course_dict(course_key) + course = CourseOverview.get_from_id(course_key) + modes = CourseMode.modes_for_course_dict(course=course) verified_mode = modes.get(CourseMode.VERIFIED) if (verified_mode is None or user_group == FULL_ACCESS or user_group in allowed_groups): @@ -84,10 +87,19 @@ class ContentTypeGatingPartition(UserPartition): ecommerce_checkout_link = self._get_checkout_link(user, verified_mode.sku) request = crum.get_current_request() + + if can_receive_discount(user, course): + price_before_discount = verified_mode.min_price + upgrade_price = "{:0.2f}".format(price_before_discount * ((100.0 - discount_percentage()) / 100)) + else: + price_before_discount = None + upgrade_price = verified_mode.min_price + frag = Fragment(render_to_string('content_type_gating/access_denied_message.html', { 'mobile_app': request and is_request_from_mobile_app(request), 'ecommerce_checkout_link': ecommerce_checkout_link, - 'min_price': str(verified_mode.min_price) + 'min_price': upgrade_price, + 'price_before_discount': price_before_discount, })) return frag diff --git a/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html b/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html index b47de960f5..a215a539d1 100644 --- a/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html +++ b/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html @@ -12,7 +12,12 @@ {% if not mobile_app and ecommerce_checkout_link %} - {% trans "Upgrade to unlock" %} (${{min_price}} USD) + {% trans "Upgrade to unlock" %} + {% if price_before_discount %} + (${{min_price}} USD ${{price_before_discount}} USD) + {% else %} + (${{min_price}} USD) + {% endif %} {% endif %} diff --git a/openedx/features/content_type_gating/tests/test_access.py b/openedx/features/content_type_gating/tests/test_access.py index 2d8d2d7ad0..c72f8d248a 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -20,6 +20,7 @@ from pyquery import PyQuery as pq from six.moves.html_parser import HTMLParser +from course_modes.models import CourseMode from course_api.blocks.api import get_blocks from course_modes.tests.factories import CourseModeFactory from experiments.models import ExperimentData, ExperimentKeyValue @@ -43,6 +44,7 @@ from openedx.core.djangoapps.django_comment_common.models import ( from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory from openedx.core.djangoapps.util.testing import TestConditionalContent from openedx.core.lib.url_utils import quote_slashes +from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import ContentTypeGatingPartition @@ -780,6 +782,33 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase): request_factory=self.factory, ) + @ddt.data(True, False) + def test_discount_display(self, has_discount): + verified_mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) + verified_mode.min_price = 100 + verified_mode.save() + + with DISCOUNT_APPLICABILITY_FLAG.override(has_discount): + with patch.object(ContentTypeGatingPartition, '_get_checkout_link', return_value='#'): + block_content = _get_content_from_lms_index( + block=self.blocks_dict['problem'], + user_id=self.audit_user.id, + course=self.course, + request_factory=self.factory, + ) + + assert 'content-paywall' in block_content + assert 'certA_1' in block_content + + if has_discount: + assert '$100' in block_content + assert '$85' in block_content + else: + assert '' not in block_content + assert '$85' not in block_content + assert '$100' in block_content + + @override_settings(FIELD_OVERRIDE_PROVIDERS=( 'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride', From 26cb18b01dabad5c7d2368ad8052f711e68fabbb Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 11 Jun 2019 13:02:22 -0400 Subject: [PATCH 3/6] Fix test name typo --- openedx/features/content_type_gating/tests/test_partitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/features/content_type_gating/tests/test_partitions.py b/openedx/features/content_type_gating/tests/test_partitions.py index a6e6a6f8f8..4e56593136 100644 --- a/openedx/features/content_type_gating/tests/test_partitions.py +++ b/openedx/features/content_type_gating/tests/test_partitions.py @@ -117,7 +117,7 @@ class TestContentTypeGatingPartition(CacheIsolationTestCase): message = partition.access_denied_message(mock_block.scope_ids.usage_id, global_staff, FULL_ACCESS, 'test_allowed_group') self.assertIsNone(message) - def test_acess_denied_fragment_for_null_request(self): + def test_access_denied_fragment_for_null_request(self): """ Verifies the access denied fragment is visible when HTTP request is not available. From f842717c49d7e358cc59fa4fdf20c3d303e8b5a5 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 11 Jun 2019 15:47:52 -0400 Subject: [PATCH 4/6] Add strike-out price to LMS Course Home sock --- .../course_experience/course-sock-fragment.html | 11 ++++++++++- .../features/course_experience/views/course_sock.py | 13 +++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) 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 932c703dba..f5ccbe72db 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 @@ -60,7 +60,16 @@ from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG Example Certificate Image
- ${Text(_('Upgrade ({course_price})')).format(course_price=HTML(course_price))} + % if course_price_before_discount: + ${Text(_('Upgrade ({course_price} {startdel}{course_price_before_discount}{enddel})')).format( + course_price=HTML(course_price), + course_price_before_discount=HTML(course_price_before_discount), + startdel=HTML(""), + enddel=HTML(""), + )} + % else: + ${Text(_('Upgrade ({course_price})')).format(course_price=HTML(course_price))} + % endif
diff --git a/openedx/features/course_experience/views/course_sock.py b/openedx/features/course_experience/views/course_sock.py index 7d73db331b..50592b14f5 100644 --- a/openedx/features/course_experience/views/course_sock.py +++ b/openedx/features/course_experience/views/course_sock.py @@ -6,9 +6,10 @@ from __future__ import absolute_import from django.template.loader import render_to_string from web_fragments.fragment import Fragment -from course_modes.models import get_cosmetic_verified_display_price +from course_modes.models import get_cosmetic_verified_display_price, get_course_prices, format_course_price from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid from openedx.core.djangoapps.plugin_api.views import EdxFragmentView +from openedx.features.discounts.applicability import discount_percentage, can_receive_discount from student.models import CourseEnrollment @@ -30,7 +31,14 @@ class CourseSockFragmentView(EdxFragmentView): show_course_sock = verified_upgrade_link_is_valid(enrollment) if show_course_sock: upgrade_url = verified_upgrade_deadline_link(request.user, course=course) - course_price = get_cosmetic_verified_display_price(course) + + if can_receive_discount(request.user, course): + course_price_before_discount_num = get_course_prices(course, verified_only=True)[0] + course_price_before_discount = format_course_price(course_price_before_discount_num) + course_price = format_course_price("{:0.2f}".format(course_price_before_discount_num * ((100.0 - discount_percentage()) / 100))) + else: + course_price_before_discount = None + course_price = get_cosmetic_verified_display_price(course) else: upgrade_url = '' course_price = '' @@ -38,6 +46,7 @@ class CourseSockFragmentView(EdxFragmentView): context = { 'show_course_sock': show_course_sock, 'course_price': course_price, + 'course_price_before_discount': course_price_before_discount, 'course_id': course.id, 'upgrade_url': upgrade_url, } From 4152e1601641464038272d8bbc189882c94bed30 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 11 Jun 2019 15:48:42 -0400 Subject: [PATCH 5/6] Install openedx.features.discounts in Studio so that it can be imported by student --- cms/envs/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/envs/common.py b/cms/envs/common.py index 33be61f1af..e44b5f3433 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1177,6 +1177,7 @@ INSTALLED_APPS = [ 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', + 'openedx.features.discounts', 'experiments', ] From 2901d5f8109d4a100831d7890a57afc841c14b70 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 13 Jun 2019 11:59:15 -0400 Subject: [PATCH 6/6] Centralize the strike-out price formatting to make correct accessibility easier --- .../content_type_gating/partitions.py | 10 +--- .../access_denied_message.html | 7 +-- .../content_type_gating/tests/test_access.py | 41 +++++-------- .../tests/test_partitions.py | 2 + .../course-home-fragment.html | 10 +--- .../course-sock-fragment.html | 11 +--- .../tests/views/test_course_home.py | 32 ++++------- .../tests/views/test_course_sock.py | 12 ++++ .../course_experience/views/course_home.py | 14 ++--- .../course_experience/views/course_sock.py | 13 +---- .../features/discounts/tests/test_utils.py | 47 +++++++++++++++ openedx/features/discounts/utils.py | 57 +++++++++++++++++++ openedx/tests/settings.py | 1 + 13 files changed, 157 insertions(+), 100 deletions(-) create mode 100644 openedx/features/discounts/tests/test_utils.py create mode 100644 openedx/features/discounts/utils.py diff --git a/openedx/features/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py index 4c30c66d13..c6ce2d7bdf 100644 --- a/openedx/features/content_type_gating/partitions.py +++ b/openedx/features/content_type_gating/partitions.py @@ -21,7 +21,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi from openedx.core.lib.mobile_utils import is_request_from_mobile_app from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, FULL_ACCESS, LIMITED_ACCESS from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.discounts.applicability import discount_percentage, can_receive_discount +from openedx.features.discounts.utils import format_strikeout_price from xmodule.partitions.partitions import UserPartition, UserPartitionError LOG = logging.getLogger(__name__) @@ -88,18 +88,12 @@ class ContentTypeGatingPartition(UserPartition): ecommerce_checkout_link = self._get_checkout_link(user, verified_mode.sku) request = crum.get_current_request() - if can_receive_discount(user, course): - price_before_discount = verified_mode.min_price - upgrade_price = "{:0.2f}".format(price_before_discount * ((100.0 - discount_percentage()) / 100)) - else: - price_before_discount = None - upgrade_price = verified_mode.min_price + upgrade_price, _ = format_strikeout_price(user, course) frag = Fragment(render_to_string('content_type_gating/access_denied_message.html', { 'mobile_app': request and is_request_from_mobile_app(request), 'ecommerce_checkout_link': ecommerce_checkout_link, 'min_price': upgrade_price, - 'price_before_discount': price_before_discount, })) return frag diff --git a/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html b/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html index a215a539d1..f3c736dc12 100644 --- a/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html +++ b/openedx/features/content_type_gating/templates/content_type_gating/access_denied_message.html @@ -12,12 +12,7 @@ {% if not mobile_app and ecommerce_checkout_link %} - {% trans "Upgrade to unlock" %} - {% if price_before_discount %} - (${{min_price}} USD ${{price_before_discount}} USD) - {% else %} - (${{min_price}} USD) - {% endif %} + {% trans "Upgrade to unlock" %} ({{min_price}}) {% endif %} diff --git a/openedx/features/content_type_gating/tests/test_access.py b/openedx/features/content_type_gating/tests/test_access.py index c72f8d248a..65f9c38f83 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -15,7 +15,7 @@ from django.test.utils import override_settings from django.urls import reverse from django.utils import timezone from django.contrib.auth.models import User -from mock import patch +from mock import patch, Mock from pyquery import PyQuery as pq from six.moves.html_parser import HTMLParser @@ -43,8 +43,8 @@ from openedx.core.djangoapps.django_comment_common.models import ( ) from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory from openedx.core.djangoapps.util.testing import TestConditionalContent +from openedx.core.djangolib.markup import HTML from openedx.core.lib.url_utils import quote_slashes -from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import ContentTypeGatingPartition @@ -782,32 +782,21 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase): request_factory=self.factory, ) - @ddt.data(True, False) - def test_discount_display(self, has_discount): - verified_mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) - verified_mode.min_price = 100 - verified_mode.save() + @patch( + 'openedx.features.content_type_gating.partitions.format_strikeout_price', + Mock(return_value=(HTML("DISCOUNT_PRICE"), True)) + ) + def test_discount_display(self): - with DISCOUNT_APPLICABILITY_FLAG.override(has_discount): - with patch.object(ContentTypeGatingPartition, '_get_checkout_link', return_value='#'): - block_content = _get_content_from_lms_index( - block=self.blocks_dict['problem'], - user_id=self.audit_user.id, - course=self.course, - request_factory=self.factory, - ) - - assert 'content-paywall' in block_content - assert 'certA_1' in block_content - - if has_discount: - assert '$100' in block_content - assert '$85' in block_content - else: - assert '' not in block_content - assert '$85' not in block_content - assert '$100' in block_content + with patch.object(ContentTypeGatingPartition, '_get_checkout_link', return_value='#'): + block_content = _get_content_from_lms_index( + block=self.blocks_dict['problem'], + user_id=self.audit_user.id, + course=self.course, + request_factory=self.factory, + ) + assert 'DISCOUNT_PRICE' in block_content @override_settings(FIELD_OVERRIDE_PROVIDERS=( diff --git a/openedx/features/content_type_gating/tests/test_partitions.py b/openedx/features/content_type_gating/tests/test_partitions.py index 4e56593136..b05b4d76f3 100644 --- a/openedx/features/content_type_gating/tests/test_partitions.py +++ b/openedx/features/content_type_gating/tests/test_partitions.py @@ -13,6 +13,7 @@ from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, FULL_ACCESS, LIMITED_ACCESS from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import ContentTypeGatingPartition, create_content_gating_partition +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from student.tests.factories import GroupFactory from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID, UserPartitionError @@ -20,6 +21,7 @@ from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID, UserPar class TestContentTypeGatingPartition(CacheIsolationTestCase): def setUp(self): self.course_key = CourseKey.from_string('course-v1:test+course+key') + CourseOverviewFactory.create(id=self.course_key) def test_create_content_gating_partition_happy_path(self): 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 89b488e7ed..ee08433af7 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 @@ -13,7 +13,7 @@ from django.urls import reverse from lms.djangoapps.discussion.django_comment_client.permissions import has_permission from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string -from openedx.core.djangolib.markup import HTML +from openedx.core.djangolib.markup import Text, HTML from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REVIEWS_TOOL_FLAG %> @@ -124,7 +124,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV % endif % if upgrade_url and upgrade_price: -
+

${_("Pursue a verified certificate")}

@@ -134,11 +134,7 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV data-creative="sidebarupsell" data-position="sidebar-message" > - % if upgrade_price_before_discount: - ${_("Upgrade")} (${upgrade_price} ${upgrade_price_before_discount}) - % else: - ${_("Upgrade ({price})").format(price=upgrade_price)} - % endif + ${Text(_("Upgrade ({price})")).format(price=upgrade_price)}

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 f5ccbe72db..932c703dba 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 @@ -60,16 +60,7 @@ from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG Example Certificate Image
- % if course_price_before_discount: - ${Text(_('Upgrade ({course_price} {startdel}{course_price_before_discount}{enddel})')).format( - course_price=HTML(course_price), - course_price_before_discount=HTML(course_price_before_discount), - startdel=HTML(""), - enddel=HTML(""), - )} - % else: - ${Text(_('Upgrade ({course_price})')).format(course_price=HTML(course_price))} - % endif + ${Text(_('Upgrade ({course_price})')).format(course_price=HTML(course_price))}
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 d81c058dee..b64d26645a 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -44,6 +44,7 @@ from openedx.core.djangoapps.django_comment_common.models import ( ) from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag +from openedx.core.djangolib.markup import HTML from openedx.features.course_duration_limits.config import EXPERIMENT_DATA_HOLDBACK_KEY, EXPERIMENT_ID from openedx.features.course_duration_limits.models import CourseDurationLimitConfig from openedx.features.course_experience import ( @@ -53,7 +54,6 @@ from openedx.features.course_experience import ( SHOW_UPGRADE_MSG_ON_COURSE_HOME, UNIFIED_COURSE_TAB_FLAG ) -from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG from student.models import CourseEnrollment from student.tests.factories import UserFactory from util.date_utils import strftime_localized @@ -973,7 +973,7 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): self.assertIn('${price})".format(price=self.verified_mode.min_price), response.content.decode(response.charset) ) @@ -1004,27 +1004,15 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) self.assert_upgrade_message_displayed() - @ddt.data(True, False) - def test_upgrade_message_discount(self, has_discount): + @mock.patch( + 'openedx.features.course_experience.views.course_home.format_strikeout_price', + mock.Mock(return_value=(HTML("DISCOUNT_PRICE"), True)) + ) + def test_upgrade_message_discount(self): CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) - self.verified_mode.min_price = 100 - self.verified_mode.save() + with SHOW_UPGRADE_MSG_ON_COURSE_HOME.override(True): + response = self.client.get(self.url) - with FIRST_PURCHASE_OFFER_BANNER_DISPLAY.override(has_discount): - with DISCOUNT_APPLICABILITY_FLAG.override(has_discount): - response = self.client.get(self.url) - - self.assertIn('section-upgrade', response.content) - url = EcommerceService().get_checkout_page_url(self.verified_mode.sku) - self.assertIn('$100' in content - else: - assert u'$85' not in content - assert u'' not in content - assert u'Upgrade ($100)' in content + assert "DISCOUNT_PRICE" in content diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py index 78d2ca3f10..dfe65c1f6a 100644 --- a/openedx/features/course_experience/tests/views/test_course_sock.py +++ b/openedx/features/course_experience/tests/views/test_course_sock.py @@ -3,12 +3,14 @@ Tests for course verification sock """ from __future__ import absolute_import +import mock import ddt from course_modes.models import CourseMode from lms.djangoapps.commerce.models import CommerceConfiguration from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag +from openedx.core.djangolib.markup import HTML from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG from student.tests.factories import CourseEnrollmentFactory, UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -94,6 +96,16 @@ class TestCourseSockView(SharedModuleStoreTestCase): response = self.client.get(course_home_url(self.verified_course_already_enrolled)) self.assert_verified_sock_is_not_visible(self.verified_course_already_enrolled, response) + @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) + @mock.patch( + 'openedx.features.course_experience.views.course_sock.format_strikeout_price', + mock.Mock(return_value=(HTML("DISCOUNT_PRICE"), True)) + ) + def test_upgrade_message_discount(self): + response = self.client.get(course_home_url(self.verified_course)) + content = response.content.decode(response.charset) + assert "DISCOUNT_PRICE" in content + def assert_verified_sock_is_visible(self, course, response): return self.assertContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False) diff --git a/openedx/features/course_experience/views/course_home.py b/openedx/features/course_experience/views/course_home.py index 37d5ca27f0..3704918838 100644 --- a/openedx/features/course_experience/views/course_home.py +++ b/openedx/features/course_experience/views/course_home.py @@ -14,7 +14,6 @@ from django.views.decorators.csrf import ensure_csrf_cookie from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment -from course_modes.models import get_cosmetic_verified_display_price, get_course_prices, format_course_price from courseware.access import has_access from courseware.courses import can_self_enroll_in_course, get_course_info_section, get_course_with_access from lms.djangoapps.commerce.utils import EcommerceService @@ -32,7 +31,7 @@ from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_bann from openedx.features.course_duration_limits.access import generate_course_expired_fragment from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.utils import get_first_purchase_offer_banner_fragment -from openedx.features.discounts.applicability import discount_percentage +from openedx.features.discounts.utils import format_strikeout_price from student.models import CourseEnrollment from util.views import ensure_valid_course_key from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE @@ -205,17 +204,12 @@ class CourseHomeFragmentView(EdxFragmentView): # Get info for upgrade messaging upgrade_price = None upgrade_url = None - upgrade_price_before_discount = None + has_discount = False # TODO Add switch to control deployment if SHOW_UPGRADE_MSG_ON_COURSE_HOME.is_enabled(course_key) and enrollment and enrollment.upgrade_deadline: upgrade_url = EcommerceService().upgrade_url(request.user, course_key) - if offer_banner_fragment: - upgrade_price_before_discount_num = get_course_prices(course, verified_only=True)[0] - upgrade_price_before_discount = format_course_price(upgrade_price_before_discount_num) - upgrade_price = format_course_price("{:0.2f}".format(upgrade_price_before_discount_num * ((100.0 - discount_percentage()) / 100))) - else: - upgrade_price = get_cosmetic_verified_display_price(course) + upgrade_price, has_discount = format_strikeout_price(request.user, course) # Render the course home fragment context = { @@ -243,7 +237,7 @@ class CourseHomeFragmentView(EdxFragmentView): 'uses_pattern_library': True, 'upgrade_price': upgrade_price, 'upgrade_url': upgrade_url, - 'upgrade_price_before_discount': upgrade_price_before_discount, + 'has_discount': has_discount, } html = render_to_string('course_experience/course-home-fragment.html', context) return Fragment(html) diff --git a/openedx/features/course_experience/views/course_sock.py b/openedx/features/course_experience/views/course_sock.py index 50592b14f5..f0ea47f219 100644 --- a/openedx/features/course_experience/views/course_sock.py +++ b/openedx/features/course_experience/views/course_sock.py @@ -6,10 +6,9 @@ from __future__ import absolute_import from django.template.loader import render_to_string from web_fragments.fragment import Fragment -from course_modes.models import get_cosmetic_verified_display_price, get_course_prices, format_course_price from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid from openedx.core.djangoapps.plugin_api.views import EdxFragmentView -from openedx.features.discounts.applicability import discount_percentage, can_receive_discount +from openedx.features.discounts.utils import format_strikeout_price from student.models import CourseEnrollment @@ -31,14 +30,7 @@ class CourseSockFragmentView(EdxFragmentView): show_course_sock = verified_upgrade_link_is_valid(enrollment) if show_course_sock: upgrade_url = verified_upgrade_deadline_link(request.user, course=course) - - if can_receive_discount(request.user, course): - course_price_before_discount_num = get_course_prices(course, verified_only=True)[0] - course_price_before_discount = format_course_price(course_price_before_discount_num) - course_price = format_course_price("{:0.2f}".format(course_price_before_discount_num * ((100.0 - discount_percentage()) / 100))) - else: - course_price_before_discount = None - course_price = get_cosmetic_verified_display_price(course) + course_price, _ = format_strikeout_price(request.user, course) else: upgrade_url = '' course_price = '' @@ -46,7 +38,6 @@ class CourseSockFragmentView(EdxFragmentView): context = { 'show_course_sock': show_course_sock, 'course_price': course_price, - 'course_price_before_discount': course_price_before_discount, 'course_id': course.id, 'upgrade_url': upgrade_url, } diff --git a/openedx/features/discounts/tests/test_utils.py b/openedx/features/discounts/tests/test_utils.py new file mode 100644 index 0000000000..e3e258f48f --- /dev/null +++ b/openedx/features/discounts/tests/test_utils.py @@ -0,0 +1,47 @@ +""" +Tests of the openedx.features.discounts.utils module. +""" +from unittest import TestCase +from mock import patch, Mock +import six + +import ddt + +from .. import utils + + +@ddt.ddt +class TestStrikeoutPrice(TestCase): + """ + Tests of the strike-out pricing for discounts. + """ + def test_not_eligible(self): + with patch.multiple( + utils, + can_receive_discount=Mock(return_value=False), + get_course_prices=Mock(return_value=(100, None)) + ): + content, has_discount = utils.format_strikeout_price(Mock(name='user'), Mock(name='course')) + + assert six.text_type(content) == u"$100" + assert not has_discount + + @ddt.data((15, 100, "$100", "$85",), (50, 50, "$50", "$25"), (10, 99, "$99", "$89.10")) + @ddt.unpack + def test_eligible_eligible(self, discount_percentage, base_price, formatted_base_price, final_price): + with patch.multiple( + utils, + can_receive_discount=Mock(return_value=True), + get_course_prices=Mock(return_value=(base_price, None)), + discount_percentage=Mock(return_value=discount_percentage) + ): + content, has_discount = utils.format_strikeout_price(Mock(name='user'), Mock(name='course')) + + assert six.text_type(content) == ( + u"" + u"Original price: {original_price}, discount price: " + u"" + u"{discount_price} " + u"" + ).format(original_price=formatted_base_price, discount_price=final_price) + assert has_discount diff --git a/openedx/features/discounts/utils.py b/openedx/features/discounts/utils.py new file mode 100644 index 0000000000..8c490bea7f --- /dev/null +++ b/openedx/features/discounts/utils.py @@ -0,0 +1,57 @@ +""" +Utility functions for working with discounts and discounted pricing. +""" + +from django.utils.translation import ugettext as _ +from course_modes.models import get_course_prices, format_course_price +from openedx.core.djangolib.markup import HTML + +from .applicability import can_receive_discount, discount_percentage + + +def format_strikeout_price(user, course, base_price=None): + """ + 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). + """ + if base_price is None: + base_price = get_course_prices(course, verified_only=True)[0] + + original_price = format_course_price(base_price) + + if 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)) + else: + discount_price = format_course_price("{:0.2f}".format(discount_price)) + + # Separate out this string because it has a lot of syntax but no actual information for + # translators to translate + formatted_discount_price = HTML( + u"{s_dp}{discount_price}{e_p} {s_st}{s_op}{original_price}{e_p}{e_st}" + ).format( + original_price=original_price, + discount_price=discount_price, + s_op=HTML(""), + s_dp=HTML(""), + s_st=HTML(""), + e_st=HTML(""), + ) + + return ( + HTML(_( + u"{s_sr}Original price: {s_op}{original_price}{e_p}, discount price: {e_sr}{formatted_discount_price}" + )).format( + original_price=original_price, + formatted_discount_price=formatted_discount_price, + s_sr=HTML(""), + s_op=HTML(""), + e_p=HTML(""), + e_sr=HTML(""), + ), + True + ) + else: + return (HTML(u"{}").format(original_price), False) diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index d23c5db48a..4dd346c915 100644 --- a/openedx/tests/settings.py +++ b/openedx/tests/settings.py @@ -86,6 +86,7 @@ INSTALLED_APPS = ( 'experiments', 'openedx.features.content_type_gating', 'openedx.features.course_duration_limits', + 'openedx.features.discounts', 'milestones', 'celery_utils', 'waffle',