From eef72b5ab77d757566dd17e62d28005c53367f15 Mon Sep 17 00:00:00 2001 From: Michael Terry Date: Fri, 11 Dec 2020 09:27:27 -0500 Subject: [PATCH] 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. --- .../course_home_api/outline/v1/serializers.py | 2 + .../outline/v1/tests/test_views.py | 35 ++- .../course_home_api/outline/v1/views.py | 20 +- lms/djangoapps/courseware/tests/test_views.py | 4 +- .../djangoapps/courseware_api/serializers.py | 5 +- .../core/djangoapps/courseware_api/views.py | 38 +++- .../features/course_duration_limits/access.py | 97 ++++++--- .../tests/test_access.py | 29 +++ .../tests/views/test_course_home.py | 4 +- .../features/discounts/tests/test_utils.py | 50 ++++- openedx/features/discounts/utils.py | 206 +++++++++++------- 11 files changed, 354 insertions(+), 136 deletions(-) diff --git a/lms/djangoapps/course_home_api/outline/v1/serializers.py b/lms/djangoapps/course_home_api/outline/v1/serializers.py index a51e0bbad9..6eb5c78401 100644 --- a/lms/djangoapps/course_home_api/outline/v1/serializers.py +++ b/lms/djangoapps/course_home_api/outline/v1/serializers.py @@ -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() diff --git a/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py b/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py index d0022db7d9..751f92c044 100644 --- a/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py +++ b/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py @@ -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='

Offer

')) - 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'], '

Offer

') + + 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='

Expired

')) - def test_course_expired_html(self): - CourseEnrollment.enroll(self.user, self.course.id) - self.assertEqual(self.client.get(self.url).data['course_expired_html'], '

Expired

') + 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) diff --git a/lms/djangoapps/course_home_api/outline/v1/views.py b/lms/djangoapps/course_home_api/outline/v1/views.py index c120f8e778..548e5733cb 100644 --- a/lms/djangoapps/course_home_api/outline/v1/views.py +++ b/lms/djangoapps/course_home_api/outline/v1/views.py @@ -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, diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 2d81e69b19..173c00f288 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -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): diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py index ad449a60ae..04c7afc15a 100644 --- a/openedx/core/djangoapps/courseware_api/serializers.py +++ b/openedx/core/djangoapps/courseware_api/serializers.py @@ -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() diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 35a8b3aee4..8e71fa7158 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -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 diff --git a/openedx/features/course_duration_limits/access.py b/openedx/features/course_duration_limits/access.py index 3b35cb2cb8..b347dcbffc 100644 --- a/openedx/features/course_duration_limits/access.py +++ b/openedx/features/course_duration_limits/access.py @@ -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'{formatted_date_localized}' +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'').format( - upgrade_link=verified_upgrade_deadline_link(user=user, course=course) - ), + a_open=HTML('').format(upgrade_link=upgrade_url), sronly_span_open=HTML(''), span_close=HTML(''), a_close=HTML(''), @@ -206,9 +233,7 @@ def generate_course_expired_fragment(user, course): def generate_fragment_from_message(message): - return Fragment(HTML(u"""\ -
{}
- """).format(message)) + return Fragment(HTML('
{}
').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( diff --git a/openedx/features/course_duration_limits/tests/test_access.py b/openedx/features/course_duration_limits/tests/test_access.py index 37d0f17dec..b7a9527739 100644 --- a/openedx/features/course_duration_limits/tests/test_access.py +++ b/openedx/features/course_duration_limits/tests/test_access.py @@ -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), diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index 5bf97e406b..b8f7498e1e 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -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): '''.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 ) diff --git a/openedx/features/discounts/tests/test_utils.py b/openedx/features/discounts/tests/test_utils.py index 0e443a372f..098c9d0119 100644 --- a/openedx/features/discounts/tests/test_utils.py +++ b/openedx/features/discounts/tests/test_utils.py @@ -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"" ).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)) diff --git a/openedx/features/discounts/utils.py b/openedx/features/discounts/utils.py index 0352a91725..f25e981c22 100644 --- a/openedx/features/discounts/utils.py +++ b/openedx/features/discounts/utils.py @@ -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(""), + s_dp=HTML(""), + s_st=HTML("'), + e_st=HTML(''), + ) + + 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(""), + s_op=HTML(""), + e_p=HTML(''), + e_sr=HTML(''), + ) + ) + + +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(""), - s_dp=HTML(""), - s_st=HTML(""), - e_st=HTML(""), - ) - - 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(""), - s_op=HTML(""), - e_p=HTML(""), - e_sr=HTML(""), - ), - True - ) + if discounted_price is None: + return HTML("{}").format(original_price), False else: - return (HTML(u"{}").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'').format( - upgrade_link=verified_upgrade_deadline_link(user=user, course=course) - ), - a_close=HTML(''), - b_open=HTML(''), - code=Text('BIENVENIDOAEDX') if get_language() == 'es-419' else Text('EDXWELCOME'), - b_close=HTML(''), - br=HTML('
'), - banner_open=HTML( - '
' - '' - ), - discount_expiration_date=discount_expiration_date.strftime(u'%B %d'), - percentage=discount_percentage(course), - span_close=HTML(''), - div_close=HTML('
'), - 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('').format(upgrade_link=data['upgrade_url']), + a_close=HTML(''), + b_open=HTML(''), + code=Text(data['code']), + b_close=HTML(''), + br=HTML('
'), + banner_open=HTML( + '
' + '' + ), + discount_expiration_date=data['expiration_date'].strftime('%B %d'), + percentage=data['percentage'], + span_close=HTML(''), + div_close=HTML('
'), + 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