Merge pull request #20789 from cpennington/first-enroll-discount-strikeout
Add strike-out price to LMS Course Home sidebar
This commit is contained in:
@@ -1177,6 +1177,7 @@ INSTALLED_APPS = [
|
||||
|
||||
'openedx.features.course_duration_limits',
|
||||
'openedx.features.content_type_gating',
|
||||
'openedx.features.discounts',
|
||||
'experiments',
|
||||
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
47
openedx/features/discounts/tests/test_utils.py
Normal file
47
openedx/features/discounts/tests/test_utils.py
Normal file
@@ -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
|
||||
57
openedx/features/discounts/utils.py
Normal file
57
openedx/features/discounts/utils.py
Normal file
@@ -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)
|
||||
@@ -86,6 +86,7 @@ INSTALLED_APPS = (
|
||||
'experiments',
|
||||
'openedx.features.content_type_gating',
|
||||
'openedx.features.course_duration_limits',
|
||||
'openedx.features.discounts',
|
||||
'milestones',
|
||||
'celery_utils',
|
||||
'waffle',
|
||||
|
||||
Reference in New Issue
Block a user