diff --git a/cms/envs/common.py b/cms/envs/common.py index 379794faea0c5b77edee167f2b2c14245241d561..8070c0a931d415a787688a2d0d6f7d5118b06601 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', ] diff --git a/lms/static/sass/features/_course-upgrade-message.scss b/lms/static/sass/features/_course-upgrade-message.scss index bcc0e45ee0c3ce1b3f1e9885c1cb6caa3eb17d57..7ad6abdc68f76afecaa9844fb06eb215a73b4b8b 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/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py index b3e8c492daed9369170fd58baa80452fd9de390e..c6ce2d7bdf2f7d090e3c49d3b2afcaa3cb550c98 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.utils import format_strikeout_price 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,13 @@ class ContentTypeGatingPartition(UserPartition): ecommerce_checkout_link = self._get_checkout_link(user, verified_mode.sku) request = crum.get_current_request() + + 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': str(verified_mode.min_price) + 'min_price': upgrade_price, })) 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 b47de960f5f5d9c22bccecb8065b6b8a2d95b957..f3c736dc1292a74c044f58cfbc48a28da826bde1 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,7 @@ {% if not mobile_app and ecommerce_checkout_link %} <span class="certDIV_1" style=""> <a href="{{ecommerce_checkout_link}}" class="certA_1"> - {% trans "Upgrade to unlock" %} (${{min_price}} USD) + {% trans "Upgrade to unlock" %} ({{min_price}}) </a> </span> {% endif %} diff --git a/openedx/features/content_type_gating/tests/test_access.py b/openedx/features/content_type_gating/tests/test_access.py index 2d8d2d7ad0ea95ac9cf8e5113d6595964fc8e520..65f9c38f83aa1c4e883c09ae1990b5b72202339a 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -15,11 +15,12 @@ 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 +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 @@ -42,6 +43,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.djangolib.markup import HTML from openedx.core.lib.url_utils import quote_slashes 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 @@ -780,6 +782,22 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase): request_factory=self.factory, ) + @patch( + 'openedx.features.content_type_gating.partitions.format_strikeout_price', + Mock(return_value=(HTML("<span>DISCOUNT_PRICE</span>"), True)) + ) + def test_discount_display(self): + + 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 '<span>DISCOUNT_PRICE</span>' in block_content + @override_settings(FIELD_OVERRIDE_PROVIDERS=( 'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride', diff --git a/openedx/features/content_type_gating/tests/test_partitions.py b/openedx/features/content_type_gating/tests/test_partitions.py index a6e6a6f8f86a1416195e44096e4067932ca675b5..b05b4d76f3e850840547f795fa5ec4b588767ec8 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): @@ -117,7 +119,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. 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 c90825a9dd6aad6dd504f34f1af8018161b9a619..ee08433af7565905470d718b7a9eb93acf66f461 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 </div> % endif % if upgrade_url and upgrade_price: - <div class="section section-upgrade course-home-sidebar-upgrade"> + <div class="section section-upgrade course-home-sidebar-upgrade ${'discount' if has_discount else 'no-discount'}"> <h3 class="hd hd-6">${_("Pursue a verified certificate")}</h3> <img src="https://courses.edx.org/static/images/edx-verified-mini-cert.png" alt=""> <div class="upgrade-container"> @@ -132,11 +132,12 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV <a class="btn-brand btn-upgrade" href="${upgrade_url}" data-creative="sidebarupsell" - data-position="sidebar-message"> - ${_("Upgrade ({price})").format(price=upgrade_price)} + data-position="sidebar-message" + > + ${Text(_("Upgrade ({price})")).format(price=upgrade_price)} </a> </p> - <p><button class="btn-link btn-small promo-learn-more">${_('Learn More')}</button></p> + <p><button class="btn-link btn-small promo-learn-more">${_('Learn More')}</button></p> </div> </div> % 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 3e0a0a92a40c1c1cf0e2100b8be286c52477bd08..b64d26645af46c4d3cd1a8193a7cbdc4fcff357b 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 ( @@ -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 @@ -971,7 +973,7 @@ class CourseHomeFragmentViewTests(ModuleStoreTestCase): self.assertIn('<a class="btn-brand btn-upgrade"', response.content) self.assertIn(url, response.content) self.assertIn( - u'Upgrade (${price})'.format(price=self.verified_mode.min_price), + u"Upgrade (<span class='price'>${price}</span>)".format(price=self.verified_mode.min_price), response.content.decode(response.charset) ) @@ -1001,3 +1003,16 @@ 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() + + @mock.patch( + 'openedx.features.course_experience.views.course_home.format_strikeout_price', + mock.Mock(return_value=(HTML("<span>DISCOUNT_PRICE</span>"), True)) + ) + def test_upgrade_message_discount(self): + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + + with SHOW_UPGRADE_MSG_ON_COURSE_HOME.override(True): + response = self.client.get(self.url) + + content = response.content.decode(response.charset) + assert "<span>DISCOUNT_PRICE</span>" 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 78d2ca3f10928195afa4f343839c13f651a14c00..dfe65c1f6a7cc41d4d9e64562e9b4ac4452566b2 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("<span>DISCOUNT_PRICE</span>"), True)) + ) + def test_upgrade_message_discount(self): + response = self.client.get(course_home_url(self.verified_course)) + content = response.content.decode(response.charset) + assert "<span>DISCOUNT_PRICE</span>" 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 8b4796ed90d376f1a715dfa09b932f6b3417a6ed..3704918838a7a503a5fd9af53b52d15820ecaba7 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 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 +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.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 @@ -204,11 +204,12 @@ class CourseHomeFragmentView(EdxFragmentView): # Get info for upgrade messaging upgrade_price = None upgrade_url = 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) - upgrade_price = get_cosmetic_verified_display_price(course) + upgrade_price, has_discount = format_strikeout_price(request.user, course) # Render the course home fragment context = { @@ -236,6 +237,7 @@ class CourseHomeFragmentView(EdxFragmentView): 'uses_pattern_library': True, 'upgrade_price': upgrade_price, 'upgrade_url': upgrade_url, + '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 7d73db331ba2c47a518b5b2f23b466252f2467fb..f0ea47f2192f25ab4e0940cde90ca3c331257081 100644 --- a/openedx/features/course_experience/views/course_sock.py +++ b/openedx/features/course_experience/views/course_sock.py @@ -6,9 +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 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.utils import format_strikeout_price from student.models import CourseEnrollment @@ -30,7 +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) - course_price = get_cosmetic_verified_display_price(course) + course_price, _ = format_strikeout_price(request.user, course) else: upgrade_url = '' course_price = '' diff --git a/openedx/features/discounts/tests/test_utils.py b/openedx/features/discounts/tests/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e3e258f48f231affc76e004bfe51ed39e26ad82a --- /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"<span class='price'>$100</span>" + 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"<span class='sr'>" + u"Original price: <span class='price original'>{original_price}</span>, discount price: " + u"</span>" + u"<span class='price discount'>{discount_price}</span> " + u"<del aria-hidden='true'><span class='price original'>{original_price}</span></del>" + ).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 0000000000000000000000000000000000000000..8c490bea7fd3d0a6180ccec65d976b7448c89d31 --- /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("<span class='price original'>"), + s_dp=HTML("<span class='price discount'>"), + s_st=HTML("<del aria-hidden='true'>"), + e_p=HTML("</span>"), + e_st=HTML("</del>"), + ) + + 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("<span class='sr'>"), + s_op=HTML("<span class='price original'>"), + e_p=HTML("</span>"), + e_sr=HTML("</span>"), + ), + True + ) + else: + return (HTML(u"<span class='price'>{}</span>").format(original_price), False) diff --git a/openedx/tests/settings.py b/openedx/tests/settings.py index d23c5db48a7224bfeccf11722b22fd4b29c479b1..4dd346c9158e89ecf80e9653e556bab6dcd82a52 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',