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

@@ -109,6 +109,7 @@ class OutlineTabSerializer(DatesBannerSerializerMixin, VerifiedModeSerializerMix
"""
Serializer for the Outline Tab
"""
access_expiration = serializers.DictField()
course_blocks = CourseBlockSerializer()
course_expired_html = serializers.CharField()
course_goals = CourseGoalsSerializer()
@@ -117,6 +118,7 @@ class OutlineTabSerializer(DatesBannerSerializerMixin, VerifiedModeSerializerMix
enroll_alert = EnrollAlertSerializer()
handouts_html = serializers.CharField()
has_ended = serializers.BooleanField()
offer = serializers.DictField()
offer_html = serializers.CharField()
resume_course = ResumeCourseSerializer()
welcome_message_html = serializers.CharField()

View File

@@ -22,6 +22,7 @@ from openedx.features.course_duration_limits.models import CourseDurationLimitCo
from openedx.features.course_experience import (
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, DISPLAY_COURSE_SOCK_FLAG, ENABLE_COURSE_GOALS,
)
from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE
@@ -165,17 +166,37 @@ class OutlineTabTestViews(BaseCourseHomeTests):
@override_experiment_waffle_flag(COURSE_HOME_MICROFRONTEND, active=True)
@override_waffle_flag(COURSE_HOME_MICROFRONTEND_OUTLINE_TAB, active=True)
@patch('lms.djangoapps.course_home_api.outline.v1.views.generate_offer_html', new=Mock(return_value='<p>Offer</p>'))
def test_offer_html(self):
def test_offer(self):
CourseEnrollment.enroll(self.user, self.course.id)
self.assertEqual(self.client.get(self.url).data['offer_html'], '<p>Offer</p>')
response = self.client.get(self.url)
self.assertIsNone(response.data['offer'])
self.assertIsNone(response.data['offer_html'])
with override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True):
response = self.client.get(self.url)
self.assertIsNotNone(response.data['offer_html'])
# Just a quick spot check that the dictionary looks like what we expect
self.assertEqual(response.data['offer']['code'], 'EDXWELCOME')
@override_experiment_waffle_flag(COURSE_HOME_MICROFRONTEND, active=True)
@override_waffle_flag(COURSE_HOME_MICROFRONTEND_OUTLINE_TAB, active=True)
@patch('lms.djangoapps.course_home_api.outline.v1.views.generate_course_expired_message', new=Mock(return_value='<p>Expired</p>'))
def test_course_expired_html(self):
CourseEnrollment.enroll(self.user, self.course.id)
self.assertEqual(self.client.get(self.url).data['course_expired_html'], '<p>Expired</p>')
def test_access_expiration(self):
enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED)
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
response = self.client.get(self.url)
self.assertIsNone(response.data['access_expiration'])
self.assertIsNone(response.data['course_expired_html'])
enrollment.update_enrollment(CourseMode.AUDIT)
response = self.client.get(self.url)
self.assertIsNotNone(response.data['course_expired_html'])
# Just a quick spot check that the dictionary looks like what we expect
deadline = enrollment.created + MIN_DURATION
self.assertEqual(response.data['access_expiration']['expiration_date'], deadline)
@override_waffle_flag(ENABLE_COURSE_GOALS, active=True)
@override_experiment_waffle_flag(COURSE_HOME_MICROFRONTEND, active=True)

View File

@@ -30,14 +30,14 @@ from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course
from lms.djangoapps.courseware.date_summary import TodaysDate
from lms.djangoapps.courseware.masquerade import setup_masquerade
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.features.course_duration_limits.access import generate_course_expired_message
from openedx.features.course_duration_limits.access import generate_course_expired_message, get_access_expiration_data
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
from openedx.features.course_experience.course_updates import (
dismiss_current_update_for_user, get_current_update_for_user,
)
from openedx.features.course_experience.utils import get_course_outline_block_tree
from openedx.features.discounts.utils import generate_offer_html
from openedx.features.discounts.utils import generate_offer_data, generate_offer_html
from common.djangoapps.student.models import CourseEnrollment
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE
from xmodule.modulestore.django import modulestore
@@ -69,6 +69,11 @@ class OutlineTabView(RetrieveAPIView):
Body consists of the following fields:
access_expiration: An object detailing when access to this course will expire
expiration_date: (str) When the access expires, in ISO 8601 notation
masquerading_expired_course: (bool) Whether this course is expired for the masqueraded user
upgrade_deadline: (str) Last chance to upgrade, in ISO 8601 notation (or None if can't upgrade anymore)
upgrade_url: (str) Upgrade linke (or None if can't upgrade anymore)
course_blocks:
blocks: List of serialized Course Block objects. Each serialization has the following fields:
id: (str) The usage ID of the block.
@@ -114,6 +119,13 @@ class OutlineTabView(RetrieveAPIView):
extra_text: (str)
handouts_html: (str) Raw HTML for the handouts section of the course info
has_ended: (bool) Indicates whether course has ended
offer: An object detailing upgrade discount information
code: (str) Checkout code
expiration_date: (str) Expiration of offer, in ISO 8601 notation
original_price: (str) Full upgrade price without checkout code; includes currency symbol
discounted_price: (str) Upgrade price with checkout code; includes currency symbol
percentage: (int) Amount of discount
upgrade_url: (str) Checkout URL
resume_course:
has_visited_course: (bool) Whether the user has ever visited the course
url: (str) The display name of the course block to resume
@@ -165,7 +177,9 @@ class OutlineTabView(RetrieveAPIView):
handouts_html = get_course_info_section(request, request.user, course, 'handouts') if show_handouts else ''
# TODO: TNL-7185 Legacy: Refactor to return the offer & expired data and format the message in the MFE
offer_data = show_enrolled and generate_offer_data(request.user, course_overview)
offer_html = show_enrolled and generate_offer_html(request.user, course_overview)
access_expiration = show_enrolled and get_access_expiration_data(request.user, course_overview)
course_expired_html = show_enrolled and generate_course_expired_message(request.user, course_overview)
welcome_message_html = show_enrolled and get_current_update_for_user(request, course)
@@ -243,6 +257,7 @@ class OutlineTabView(RetrieveAPIView):
}
data = {
'access_expiration': access_expiration or None,
'course_blocks': course_blocks,
'course_expired_html': course_expired_html or None,
'course_goals': course_goals,
@@ -251,6 +266,7 @@ class OutlineTabView(RetrieveAPIView):
'enroll_alert': enroll_alert,
'handouts_html': handouts_html or None,
'has_ended': course.has_ended(),
'offer': offer_data or None,
'offer_html': offer_html or None,
'resume_course': resume_course,
'welcome_message_html': welcome_message_html or None,

View File

@@ -269,8 +269,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 171),
(ModuleStoreEnum.Type.split, 4, 167),
(ModuleStoreEnum.Type.mongo, 10, 173),
(ModuleStoreEnum.Type.split, 4, 169),
)
@ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):

View File

@@ -78,11 +78,13 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
Compare this with CourseDetailSerializer.
"""
access_expiration = serializers.DictField()
can_show_upgrade_sock = serializers.BooleanField()
content_type_gating_enabled = serializers.BooleanField()
course_expired_message = serializers.CharField()
effort = serializers.CharField()
end = serializers.DateTimeField()
enrollment = serializers.DictField()
enrollment_start = serializers.DateTimeField()
enrollment_end = serializers.DateTimeField()
id = serializers.CharField() # pylint: disable=invalid-name
@@ -90,6 +92,7 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
media = _CourseApiMediaCollectionSerializer(source='*')
name = serializers.CharField(source='display_name_with_default_escaped')
number = serializers.CharField(source='display_number_with_default')
offer = serializers.DictField()
offer_html = serializers.CharField()
org = serializers.CharField(source='display_org_with_default')
related_programs = CourseProgramSerializer(many=True)
@@ -98,8 +101,8 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
start_display = serializers.CharField()
start_type = serializers.CharField()
pacing = serializers.CharField()
enrollment = serializers.DictField()
tabs = serializers.ListField()
user_timezone = serializers.CharField()
verified_mode = serializers.DictField()
show_calculator = serializers.BooleanField()
original_user_is_staff = serializers.BooleanField()

View File

@@ -27,6 +27,7 @@ from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.access_response import (
CoursewareMicrofrontendDisabledAccessError,
)
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
from lms.djangoapps.courseware.courses import check_course_access, get_course_by_id
from lms.djangoapps.courseware.masquerade import setup_masquerade
from lms.djangoapps.courseware.module_render import get_module_by_usage_id
@@ -39,8 +40,8 @@ from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.access import generate_course_expired_message
from openedx.features.discounts.utils import generate_offer_html
from openedx.features.course_duration_limits.access import generate_course_expired_message, get_access_expiration_data
from openedx.features.discounts.utils import generate_offer_data, generate_offer_html
from common.djangoapps.student.models import (
CourseEnrollment, CourseEnrollmentCelebration, LinkedInAddToProfileConfiguration
)
@@ -118,11 +119,19 @@ class CoursewareMeta:
is_active = self.enrollment_object.is_active
return {'mode': mode, 'is_active': is_active}
@property
def access_expiration(self):
return get_access_expiration_data(self.effective_user, self.overview)
@property
def course_expired_message(self):
# TODO: TNL-7185 Legacy: Refactor to return the expiration date and format the message in the MFE
return generate_course_expired_message(self.effective_user, self.overview)
@property
def offer(self):
return generate_offer_data(self.effective_user, self.overview)
@property
def offer_html(self):
# TODO: TNL-7185 Legacy: Refactor to return the offer data and format the message in the MFE
@@ -310,6 +319,12 @@ class CoursewareMeta:
return programs
@property
def user_timezone(self):
"""Returns the user's timezone setting (may be None)"""
user_timezone_locale = user_timezone_locale_prefs(self.request)
return user_timezone_locale['user_timezone']
class CoursewareInformation(RetrieveAPIView):
"""
@@ -325,9 +340,17 @@ class CoursewareInformation(RetrieveAPIView):
Body consists of the following fields:
* access_expiration: An object detailing when access to this course will expire
* expiration_date: (str) When the access expires, in ISO 8601 notation
* masquerading_expired_course: (bool) Whether this course is expired for the masqueraded user
* upgrade_deadline: (str) Last chance to upgrade, in ISO 8601 notation (or None if can't upgrade anymore)
* upgrade_url: (str) Upgrade linke (or None if can't upgrade anymore)
* effort: A textual description of the weekly hours of effort expected
in the course.
* end: Date the course ends, in ISO 8601 notation
* enrollment: Enrollment status of authenticated user
* mode: `audit`, `verified`, etc
* is_active: boolean
* enrollment_end: Date enrollment ends, in ISO 8601 notation
* enrollment_start: Date enrollment begins, in ISO 8601 notation
* id: A unique identifier of the course; a serialized representation
@@ -338,6 +361,13 @@ class CoursewareInformation(RetrieveAPIView):
* uri: The location of the image
* name: Name of the course
* number: Catalog number of the course
* offer: An object detailing upgrade discount information
* code: (str) Checkout code
* expiration_date: (str) Expiration of offer, in ISO 8601 notation
* original_price: (str) Full upgrade price without checkout code; includes currency symbol
* discounted_price: (str) Upgrade price with checkout code; includes currency symbol
* percentage: (int) Amount of discount
* upgrade_url: (str) Checkout URL
* org: Name of the organization that owns the course
* related_programs: A list of objects that contains program data related to the given course including:
* progress: An object containing program progress:
@@ -359,9 +389,7 @@ class CoursewareInformation(RetrieveAPIView):
* `"empty"`: no start date is specified
* pacing: Course pacing. Possible values: instructor, self
* tabs: Course tabs
* enrollment: Enrollment status of authenticated user
* mode: `audit`, `verified`, etc
* is_active: boolean
* user_timezone: User's chosen timezone setting (or null for browser default)
* can_load_course: Whether the user can view the course (AccessResponse object)
* is_staff: Whether the effective user has staff access to the course
* original_user_is_staff: Whether the original user has staff access to the course

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