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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user