TNL-7185: Send data, not rendered HTML to the learning MFE

Specifically, send data versions of course_expired_message and
offer_html.

The rendered HTML is still being sent for now, until the learning
MFE is updated to consume the data objects.
This commit is contained in:
Michael Terry
2020-12-11 09:27:27 -05:00
parent 0c57a02119
commit eef72b5ab7
11 changed files with 354 additions and 136 deletions

View File

@@ -24,7 +24,7 @@ from openedx.core.djangoapps.course_date_signals.utils import get_expected_durat
from openedx.core.djangolib.markup import HTML
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
EXPIRATION_DATE_FORMAT_STR = u'%b %-d, %Y'
EXPIRATION_DATE_FORMAT_STR = '%b %-d, %Y'
class AuditExpiredError(AccessError):
@@ -32,19 +32,19 @@ class AuditExpiredError(AccessError):
Access denied because the user's audit timespan has expired
"""
def __init__(self, user, course, expiration_date):
error_code = "audit_expired"
developer_message = u"User {} had access to {} until {}".format(user, course, expiration_date)
error_code = 'audit_expired'
developer_message = 'User {} had access to {} until {}'.format(user, course, expiration_date)
expiration_date = strftime_localized(expiration_date, EXPIRATION_DATE_FORMAT_STR)
user_message = _(u"Access expired on {expiration_date}").format(expiration_date=expiration_date)
user_message = _('Access expired on {expiration_date}').format(expiration_date=expiration_date)
try:
course_name = course.display_name_with_default
additional_context_user_message = _(u"Access to {course_name} expired on {expiration_date}").format(
additional_context_user_message = _('Access to {course_name} expired on {expiration_date}').format(
course_name=course_name,
expiration_date=expiration_date
)
except CourseOverview.DoesNotExist:
additional_context_user_message = _(u"Access to the course you were looking"
u" for expired on {expiration_date}").format(
additional_context_user_message = _('Access to the course you were looking'
' for expired on {expiration_date}').format(
expiration_date=expiration_date
)
super(AuditExpiredError, self).__init__(error_code, developer_message, user_message,
@@ -115,45 +115,74 @@ def check_course_expired(user, course):
def get_date_string():
# Creating this method to allow unit testing an issue where this string was missing the unicode prefix
return u'<span class="localized-datetime" data-format="shortDate" data-timezone="{user_timezone}" \
return '<span class="localized-datetime" data-format="shortDate" data-timezone="{user_timezone}" \
data-datetime="{formatted_date}" data-language="{language}">{formatted_date_localized}</span>'
def get_access_expiration_data(user, course):
"""
Create a dictionary of information about the access expiration for this user & course.
Used by serializers to pass onto frontends and by the LMS locally to generate HTML for template rendering.
Returns a dictionary of data, or None if no expiration is applicable.
"""
expiration_date = get_user_course_expiration_date(user, course)
if not expiration_date:
return None
enrollment = CourseEnrollment.get_enrollment(user, course.id)
if enrollment is None:
return None
now = timezone.now()
upgrade_deadline = enrollment.upgrade_deadline
if not upgrade_deadline or upgrade_deadline < now:
upgrade_deadline = enrollment.course_upgrade_deadline
if upgrade_deadline and upgrade_deadline < now:
upgrade_deadline = None
masquerading_expired_course = is_masquerading_as_specific_student(user, course.id) and expiration_date < now
return {
'expiration_date': expiration_date,
'masquerading_expired_course': masquerading_expired_course,
'upgrade_deadline': upgrade_deadline,
'upgrade_url': verified_upgrade_deadline_link(user, course=course) if upgrade_deadline else None,
}
def generate_course_expired_message(user, course):
"""
Generate the message for the user course expiration date if it exists.
"""
expiration_date = get_user_course_expiration_date(user, course)
if not expiration_date:
expiration_data = get_access_expiration_data(user, course)
if not expiration_data:
return
expiration_date = expiration_data['expiration_date']
masquerading_expired_course = expiration_data['masquerading_expired_course']
upgrade_deadline = expiration_data['upgrade_deadline']
upgrade_url = expiration_data['upgrade_url']
user_timezone_locale = user_timezone_locale_prefs(crum.get_current_request())
user_timezone = user_timezone_locale['user_timezone']
now = timezone.now()
if is_masquerading_as_specific_student(user, course.id) and now > expiration_date:
if masquerading_expired_course:
upgrade_message = _('This learner does not have access to this course. '
u'Their access expired on {expiration_date}.')
'Their access expired on {expiration_date}.')
return HTML(upgrade_message).format(
expiration_date=strftime_localized(expiration_date, EXPIRATION_DATE_FORMAT_STR)
)
else:
enrollment = CourseEnrollment.get_enrollment(user, course.id)
if enrollment is None:
return
upgrade_deadline = enrollment.upgrade_deadline
if (not upgrade_deadline) or (upgrade_deadline < now):
upgrade_deadline = enrollment.course_upgrade_deadline
expiration_message = _(u'{strong_open}Audit Access Expires {expiration_date}{strong_close}'
u'{line_break}You lose all access to this course, including your progress, on '
u'{expiration_date}.')
upgrade_deadline_message = _(u'{line_break}Upgrade by {upgrade_deadline} to get unlimited access to the course '
u'as long as it exists on the site. {a_open}Upgrade now{sronly_span_open} to '
u'retain access past {expiration_date}{span_close}{a_close}')
expiration_message = _('{strong_open}Audit Access Expires {expiration_date}{strong_close}'
'{line_break}You lose all access to this course, including your progress, on '
'{expiration_date}.')
upgrade_deadline_message = _('{line_break}Upgrade by {upgrade_deadline} to get unlimited access to the course '
'as long as it exists on the site. {a_open}Upgrade now{sronly_span_open} to '
'retain access past {expiration_date}{span_close}{a_close}')
full_message = expiration_message
if upgrade_deadline and now < upgrade_deadline:
if upgrade_deadline and upgrade_url:
full_message += upgrade_deadline_message
using_upgrade_messaging = True
else:
@@ -176,9 +205,7 @@ def generate_course_expired_message(user, course):
)
return HTML(full_message).format(
a_open=HTML(u'<a id="FBE_banner" href="{upgrade_link}">').format(
upgrade_link=verified_upgrade_deadline_link(user=user, course=course)
),
a_open=HTML('<a id="FBE_banner" href="{upgrade_link}">').format(upgrade_link=upgrade_url),
sronly_span_open=HTML('<span class="sr-only">'),
span_close=HTML('</span>'),
a_close=HTML('</a>'),
@@ -206,9 +233,7 @@ def generate_course_expired_fragment(user, course):
def generate_fragment_from_message(message):
return Fragment(HTML(u"""\
<div class="course-expiration-message">{}</div>
""").format(message))
return Fragment(HTML('<div class="course-expiration-message">{}</div>').format(message))
def generate_course_expired_fragment_from_key(user, course_key):
@@ -220,7 +245,7 @@ def generate_course_expired_fragment_from_key(user, course_key):
shouldn't show a course expired message for this user.
"""
request_cache = RequestCache('generate_course_expired_fragment_from_key')
cache_key = u'message:{},{}'.format(user.id, course_key)
cache_key = 'message:{},{}'.format(user.id, course_key)
cache_response = request_cache.get_cached_response(cache_key)
if cache_response.is_found:
cached_message = cache_response.value
@@ -243,7 +268,7 @@ def course_expiration_wrapper(user, block, view, frag, context): # pylint: disa
An XBlock wrapper that prepends a message to the beginning of a vertical if
a user's course is about to expire.
"""
if block.category != "vertical":
if block.category != 'vertical':
return frag
course_expiration_fragment = generate_course_expired_fragment_from_key(

View File

@@ -19,6 +19,7 @@ from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.features.course_duration_limits.access import (
generate_course_expired_message,
get_access_expiration_data,
get_user_course_duration,
get_user_course_expiration_date
)
@@ -43,6 +44,34 @@ class TestAccess(CacheIsolationTestCase):
# But also that the machine-readable version is in there
self.assertIn('data-datetime="%s"' % date.isoformat(), message)
def test_get_access_expiration_data(self):
enrollment = CourseEnrollmentFactory()
overview = enrollment.course
user = enrollment.user
now = timezone.now()
upgrade_deadline = now + timedelta(days=2)
CourseModeFactory(
course_id=enrollment.course.id,
mode_slug=CourseMode.VERIFIED,
expiration_datetime=upgrade_deadline,
)
CourseModeFactory(
course_id=enrollment.course.id,
mode_slug=CourseMode.AUDIT,
)
expiration_date = get_user_course_expiration_date(user, overview)
self.assertIsNotNone(expiration_date)
data = get_access_expiration_data(user, overview)
self.assertEqual(data, {
'expiration_date': expiration_date,
'masquerading_expired_course': False,
'upgrade_deadline': upgrade_deadline,
'upgrade_url': '/dashboard',
})
@ddt.data(
*itertools.product(
itertools.product([None, -2, -1, 1, 2], repeat=2),

View File

@@ -208,7 +208,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
# Fetch the view and verify the query counts
# TODO: decrease query count as part of REVO-28
with self.assertNumQueries(73, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with self.assertNumQueries(75, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
@@ -428,7 +428,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
</div>'''.format(
discount_expiration_date=discount_expiration_date,
percentage=percentage,
strikeout_price=HTML(format_strikeout_price(user, self.course, check_for_discount=False)[0]),
strikeout_price=HTML(format_strikeout_price(user, self.course)[0]),
upgrade_link=upgrade_link
)

View File

@@ -1,11 +1,21 @@
"""
Tests of the openedx.features.discounts.utils module.
"""
from unittest import TestCase
from mock import patch, Mock
import six
import ddt
import six
from django.contrib.auth.models import AnonymousUser
from django.test import TestCase
from django.utils.translation import override as override_lang
from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG, get_discount_expiration_date
from .. import utils
@@ -45,3 +55,39 @@ class TestStrikeoutPrice(TestCase):
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
@override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True)
class TestOfferData(TestCase):
"""
Tests of the generate_offer_data call.
"""
def setUp(self):
super().setUp()
self.user = UserFactory()
self.overview = CourseOverviewFactory()
CourseModeFactory(course_id=self.overview.id, mode_slug=CourseMode.AUDIT)
CourseModeFactory(course_id=self.overview.id, mode_slug=CourseMode.VERIFIED, min_price=149)
CourseEnrollment.enroll(self.user, self.overview.id, CourseMode.AUDIT)
def test_happy_path(self):
self.assertEqual(utils.generate_offer_data(self.user, self.overview), {
'code': 'EDXWELCOME',
'expiration_date': get_discount_expiration_date(self.user, self.overview),
'original_price': '$149',
'discounted_price': '$126.65',
'percentage': 15,
'upgrade_url': '/dashboard',
})
def test_spanish_code(self):
with override_lang('es-419'):
self.assertEqual(utils.generate_offer_data(self.user, self.overview)['code'], 'BIENVENIDOAEDX')
def test_anonymous(self):
self.assertIsNone(utils.generate_offer_data(AnonymousUser(), self.overview))
@patch('openedx.features.discounts.utils.can_receive_discount', return_value=False)
def test_no_discount(self, _mock):
self.assertIsNone(utils.generate_offer_data(self.user, self.overview))

View File

@@ -29,7 +29,7 @@ def offer_banner_wrapper(user, block, view, frag, context): # pylint: disable=W
A wrapper that prepends the First Purchase Discount banner if
the user hasn't upgraded yet.
"""
if block.category != "vertical":
if block.category != 'vertical':
return frag
offer_banner_fragment = get_first_purchase_offer_banner_fragment_from_key(
@@ -50,52 +50,111 @@ def offer_banner_wrapper(user, block, view, frag, context): # pylint: disable=W
return offer_banner_fragment
def format_strikeout_price(user, course, base_price=None, check_for_discount=True):
def _get_discount_prices(user, course, assume_discount=False):
"""
Return a tuple of (original, discounted, percentage)
If assume_discount is True, we do not check if a discount applies and just go ahead with discount math anyway.
Each returned price is a string with appropriate currency formatting added already.
discounted and percentage will be returned as None if no discount is applicable.
"""
base_price = get_course_prices(course, verified_only=True)[0]
can_discount = assume_discount or can_receive_discount(user, course)
if can_discount:
percentage = discount_percentage(course)
discounted_price = base_price * ((100.0 - percentage) / 100)
if discounted_price: # leave 0 prices alone, as format_course_price below will adjust to 'Free'
if discounted_price == int(discounted_price):
discounted_price = '{:0.0f}'.format(discounted_price)
else:
discounted_price = '{:0.2f}'.format(discounted_price)
return format_course_price(base_price), format_course_price(discounted_price), percentage
else:
return format_course_price(base_price), None, None
def generate_offer_data(user, course):
"""
Create a dictionary of information about the current discount offer.
Used by serializers to pass onto frontends and by the LMS locally to generate HTML for template rendering.
Returns a dictionary of data, or None if no offer is applicable.
"""
if not user or not course or user.is_anonymous:
return None
ExperimentData.objects.get_or_create(
user=user, experiment_id=REV1008_EXPERIMENT_ID, key=str(course),
defaults={
'value': datetime.now(tz=pytz.UTC).strftime('%Y-%m-%d %H:%M:%S%z'),
},
)
expiration_date = get_discount_expiration_date(user, course)
if not expiration_date:
return None
if not can_receive_discount(user, course, discount_expiration_date=expiration_date):
return None
original, discounted, percentage = _get_discount_prices(user, course, assume_discount=True)
return {
'code': 'BIENVENIDOAEDX' if get_language() == 'es-419' else 'EDXWELCOME',
'expiration_date': expiration_date,
'original_price': original,
'discounted_price': discounted,
'percentage': percentage,
'upgrade_url': verified_upgrade_deadline_link(user, course=course),
}
def _format_discounted_price(original_price, discount_price):
"""Helper method that returns HTML containing a strikeout price with discount."""
# Separate out this string because it has a lot of syntax but no actual information for
# translators to translate
formatted_discount_price = HTML(
'{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(_(
'{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-only'>"),
s_op=HTML("<span class='price original'>"),
e_p=HTML('</span>'),
e_sr=HTML('</span>'),
)
)
def format_strikeout_price(user, course):
"""
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, discounted_price, _ = _get_discount_prices(user, course)
original_price = format_course_price(base_price)
if not check_for_discount or can_receive_discount(user, course):
discount_price = base_price * ((100.0 - discount_percentage(course)) / 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-only'>"),
s_op=HTML("<span class='price original'>"),
e_p=HTML("</span>"),
e_sr=HTML("</span>"),
),
True
)
if discounted_price is None:
return HTML("<span class='price'>{}</span>").format(original_price), False
else:
return (HTML(u"<span class='price'>{}</span>").format(original_price), False)
return _format_discounted_price(original_price, discounted_price), True
def generate_offer_html(user, course):
@@ -105,44 +164,33 @@ def generate_offer_html(user, course):
Returns a openedx.core.djangolib.markup.HTML object, or None if the user
should not be shown an offer message.
"""
if user and not user.is_anonymous and course:
now = datetime.now(tz=pytz.UTC).strftime(u"%Y-%m-%d %H:%M:%S%z")
saw_banner = ExperimentData.objects.filter(
user=user, experiment_id=REV1008_EXPERIMENT_ID, key=str(course)
)
if not saw_banner:
ExperimentData.objects.create(
user=user, experiment_id=REV1008_EXPERIMENT_ID, key=str(course), value=now
)
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}Use code {b_open}{code}{b_close} at checkout! '
u'{a_open}Upgrade Now{a_close}{div_close}')
data = generate_offer_data(user, course)
if not data:
return None
message_html = HTML(offer_message).format(
a_open=HTML(u'<a id="welcome" href="{upgrade_link}">').format(
upgrade_link=verified_upgrade_deadline_link(user=user, course=course)
),
a_close=HTML('</a>'),
b_open=HTML('<b>'),
code=Text('BIENVENIDOAEDX') if get_language() == 'es-419' else Text('EDXWELCOME'),
b_close=HTML('</b>'),
br=HTML('<br>'),
banner_open=HTML(
'<div class="first-purchase-offer-banner" role="note">'
'<span class="first-purchase-offer-banner-bold"><b>'
),
discount_expiration_date=discount_expiration_date.strftime(u'%B %d'),
percentage=discount_percentage(course),
span_close=HTML('</b></span>'),
div_close=HTML('</div>'),
strikeout_price=HTML(format_strikeout_price(user, course, check_for_discount=False)[0])
)
return message_html
return None
# Translator: xgettext:no-python-format
offer_message = _('{banner_open} Upgrade by {discount_expiration_date} and save {percentage}% '
'[{strikeout_price}]{span_close}{br}Use code {b_open}{code}{b_close} at checkout! '
'{a_open}Upgrade Now{a_close}{div_close}')
message_html = HTML(offer_message).format(
a_open=HTML('<a id="welcome" href="{upgrade_link}">').format(upgrade_link=data['upgrade_url']),
a_close=HTML('</a>'),
b_open=HTML('<b>'),
code=Text(data['code']),
b_close=HTML('</b>'),
br=HTML('<br>'),
banner_open=HTML(
'<div class="first-purchase-offer-banner" role="note">'
'<span class="first-purchase-offer-banner-bold"><b>'
),
discount_expiration_date=data['expiration_date'].strftime('%B %d'),
percentage=data['percentage'],
span_close=HTML('</b></span>'),
div_close=HTML('</div>'),
strikeout_price=_format_discounted_price(data['original_price'], data['discounted_price']),
)
return message_html
def get_first_purchase_offer_banner_fragment(user, course):
@@ -166,7 +214,7 @@ def get_first_purchase_offer_banner_fragment_from_key(user, course_key):
shouldn't show a first purchase offer message for this user.
"""
request_cache = RequestCache('get_first_purchase_offer_banner_fragment_from_key')
cache_key = u'html:{},{}'.format(user.id, course_key)
cache_key = 'html:{},{}'.format(user.id, course_key)
cache_response = request_cache.get_cached_response(cache_key)
if cache_response.is_found:
cached_html = cache_response.value