From d738188550d0593365430e92adf49d3541b300db Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Mon, 27 Jun 2016 11:59:17 -0400 Subject: [PATCH] Add upgrade section to program detail course cards. Displays a message and link when a user is enrolled in a program's course run but must upgrade in order to be eligible for the program certificate. ECOM-4220. --- lms/djangoapps/learner_dashboard/views.py | 5 +- .../models/course_card_model.js | 9 +- .../views/certificate_status_view.js | 7 +- .../views/course_card_view.js | 19 ++- .../views/upgrade_message_view.js | 38 ++++++ .../course_card_view_spec.js | 91 +++++++++---- lms/static/sass/elements/_course-card.scss | 1 + .../certificate_status.underscore | 6 +- .../learner_dashboard/course_card.underscore | 1 + .../upgrade_message.underscore | 12 ++ .../djangoapps/programs/tests/test_utils.py | 127 +++++++++++++----- openedx/core/djangoapps/programs/utils.py | 65 ++++++--- 12 files changed, 289 insertions(+), 92 deletions(-) create mode 100644 lms/static/js/learner_dashboard/views/upgrade_message_view.js create mode 100644 lms/templates/learner_dashboard/upgrade_message.underscore diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py index 4ba5cab92c..9f3e59829a 100644 --- a/lms/djangoapps/learner_dashboard/views.py +++ b/lms/djangoapps/learner_dashboard/views.py @@ -67,8 +67,9 @@ def program_details(request, program_id): urls = { 'program_listing_url': reverse('program_listing_view'), 'track_selection_url': strip_course_id( - reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY})), - 'commerce_api_url': reverse('commerce_api:v0:baskets:create') + reverse('course_modes_choose', kwargs={'course_id': FAKE_COURSE_KEY}) + ), + 'commerce_api_url': reverse('commerce_api:v0:baskets:create'), } context = { diff --git a/lms/static/js/learner_dashboard/models/course_card_model.js b/lms/static/js/learner_dashboard/models/course_card_model.js index 05eacb8aed..51bc9b5c75 100644 --- a/lms/static/js/learner_dashboard/models/course_card_model.js +++ b/lms/static/js/learner_dashboard/models/course_card_model.js @@ -65,17 +65,18 @@ course_key: runMode.course_key, course_url: runMode.course_url || '', display_name: this.context.display_name, - start_date: runMode.start_date, end_date: runMode.end_date, + enrollable_run_modes: this.getEnrollableRunModes(), + enrollment_open_date: runMode.enrollment_open_date || '', + is_course_ended: runMode.is_course_ended, is_enrolled: runMode.is_enrolled, is_enrollment_open: runMode.is_enrollment_open, key: this.context.key, marketing_url: runMode.marketing_url || '', - is_course_ended: runMode.is_course_ended, mode_slug: runMode.mode_slug, run_key: runMode.run_key, - enrollment_open_date: runMode.enrollment_open_date || '', - enrollable_run_modes: this.getEnrollableRunModes() + start_date: runMode.start_date, + upgrade_url: runMode.upgrade_url }); } }, diff --git a/lms/static/js/learner_dashboard/views/certificate_status_view.js b/lms/static/js/learner_dashboard/views/certificate_status_view.js index 7f55e5a3d2..a0678bb98d 100644 --- a/lms/static/js/learner_dashboard/views/certificate_status_view.js +++ b/lms/static/js/learner_dashboard/views/certificate_status_view.js @@ -27,13 +27,10 @@ }, render: function() { - var data = this.model.toJSON(), - $icons; + var data = this.model.toJSON(); + data = $.extend(data, {certificateSvg: this.iconTpl()}); HtmlUtils.setHtml(this.$el, this.statusTpl(data)); - - $icons = this.$('.certificate-icon'); - HtmlUtils.setHtml($icons, this.iconTpl()); } }); } diff --git a/lms/static/js/learner_dashboard/views/course_card_view.js b/lms/static/js/learner_dashboard/views/course_card_view.js index 4836e80bcc..42bc2a6823 100644 --- a/lms/static/js/learner_dashboard/views/course_card_view.js +++ b/lms/static/js/learner_dashboard/views/course_card_view.js @@ -7,6 +7,7 @@ 'gettext', 'edx-ui-toolkit/js/utils/html-utils', 'js/learner_dashboard/models/course_enroll_model', + 'js/learner_dashboard/views/upgrade_message_view', 'js/learner_dashboard/views/certificate_status_view', 'js/learner_dashboard/views/course_enroll_view', 'text!../../../templates/learner_dashboard/course_card.underscore' @@ -18,6 +19,7 @@ gettext, HtmlUtils, EnrollModel, + UpgradeMessageView, CertificateStatusView, CourseEnrollView, pageTpl @@ -44,7 +46,8 @@ }, postRender: function(){ - var $certStatus = this.$('.certificate-status'); + var $upgradeMessage = this.$('.upgrade-message'), + $certStatus = this.$('.certificate-status'); this.enrollView = new CourseEnrollView({ $parentEl: this.$('.course-actions'), @@ -53,13 +56,23 @@ enrollModel: this.enrollModel }); - if ( this.model.get('certificate_url') ) { + if ( this.model.get('upgrade_url') ) { + this.upgradeMessage = new UpgradeMessageView({ + $el: $upgradeMessage, + model: this.model + }); + + $certStatus.remove(); + } else if ( this.model.get('certificate_url') ) { this.certificateStatus = new CertificateStatusView({ $el: $certStatus, model: this.model }); + + $upgradeMessage.remove(); } else { - // Styles are applied to the element that show if it's empty + // Styles are applied to these elements which will be visible if they're empty. + $upgradeMessage.remove(); $certStatus.remove(); } } diff --git a/lms/static/js/learner_dashboard/views/upgrade_message_view.js b/lms/static/js/learner_dashboard/views/upgrade_message_view.js new file mode 100644 index 0000000000..3928c37aef --- /dev/null +++ b/lms/static/js/learner_dashboard/views/upgrade_message_view.js @@ -0,0 +1,38 @@ +;(function (define) { + 'use strict'; + define(['backbone', + 'jquery', + 'underscore', + 'gettext', + 'edx-ui-toolkit/js/utils/html-utils', + 'text!../../../templates/learner_dashboard/upgrade_message.underscore', + 'text!../../../templates/learner_dashboard/certificate_icon.underscore' + ], + function( + Backbone, + $, + _, + gettext, + HtmlUtils, + upgradeMessageTpl, + certificateIconTpl + ) { + return Backbone.View.extend({ + messageTpl: HtmlUtils.template(upgradeMessageTpl), + iconTpl: HtmlUtils.template(certificateIconTpl), + + initialize: function(options) { + this.$el = options.$el; + this.render(); + }, + + render: function() { + var data = this.model.toJSON(); + + data = $.extend(data, {certificateSvg: this.iconTpl()}); + HtmlUtils.setHtml(this.$el, this.messageTpl(data)); + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/learner_dashboard/course_card_view_spec.js b/lms/static/js/spec/learner_dashboard/course_card_view_spec.js index 9d6ea5022d..ef1ced7787 100644 --- a/lms/static/js/spec/learner_dashboard/course_card_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/course_card_view_spec.js @@ -19,27 +19,30 @@ define([ key: 'ANUx' }, run_modes: [{ - start_date: 'Apr 25, 2016', - end_date: 'Jun 13, 2019', - course_key: 'course-v1:ANUx+ANU-ASTRO1x+3T2015', - course_url: 'http://localhost:8000/courses/course-v1:edX+DemoX+Demo_Course/info', - marketing_url: 'https://www.edx.org/course/astrophysics-exploring', + certificate_url: '', course_image_url: 'http://test.com/image1', + course_key: 'course-v1:ANUx+ANU-ASTRO1x+3T2015', + course_started: true, + course_url: 'http://localhost:8000/courses/course-v1:edX+DemoX+Demo_Course/info', + end_date: 'Jun 13, 2019', + enrollment_open_date: 'Mar 03, 2016', + is_course_ended: false, + is_enrolled: true, + is_enrollment_open: true, + marketing_url: 'https://www.edx.org/course/astrophysics-exploring', mode_slug: 'verified', run_key: '2T2016', - course_started: true, - is_enrolled: true, - is_course_ended: false, - is_enrollment_open: true, - certificate_url: '', - enrollment_open_date: 'Mar 03, 2016' + start_date: 'Apr 25, 2016', + upgrade_url: '' }] }, setupView = function(data, isEnrolled){ - data.run_modes[0].is_enrolled = isEnrolled; + var programData = $.extend({}, data); + + programData.run_modes[0].is_enrolled = isEnrolled; setFixtures('
'); - courseCardModel = new CourseCardModel(data); + courseCardModel = new CourseCardModel(programData); view = new CourseCardView({ model: courseCardModel }); @@ -86,37 +89,71 @@ define([ }); it('should only show certificate status section if a certificate has been earned', function() { - var data = context, + var data = $.extend({}, context), certUrl = 'sample-certificate'; - setupView(context, false); - expect(view.$('certificate-status').length).toEqual(0); + expect(view.$('.certificate-status').length).toEqual(0); view.remove(); + data.run_modes[0].certificate_url = certUrl; setupView(data, false); expect(view.$('.certificate-status').length).toEqual(1); expect(view.$('.certificate-status .cta-secondary').attr('href')).toEqual(certUrl); }); - it('should render the course card with coming soon', function(){ + it('should only show upgrade message section if an upgrade is required', function() { + var data = $.extend({}, context), + upgradeUrl = '/path/to/upgrade'; + + expect(view.$('.upgrade-message').length).toEqual(0); view.remove(); - context.run_modes[0].is_enrollment_open = false; - setupView(context, false); - expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url); - expect(view.$('.course-details .course-title').text().trim()).toEqual(context.display_name); + + data.run_modes[0].upgrade_url = upgradeUrl; + setupView(data, false); + expect(view.$('.upgrade-message').length).toEqual(1); + expect(view.$('.upgrade-message .cta-primary').attr('href')).toEqual(upgradeUrl); + }); + + it('should not show both the upgrade message and certificate status sections', function() { + var data = $.extend({}, context); + + // Verify that no empty elements are left in the DOM. + data.run_modes[0].upgrade_url = ''; + data.run_modes[0].certificate_url = ''; + setupView(data, false); + expect(view.$('.upgrade-message').length).toEqual(0); + expect(view.$('.certificate-status').length).toEqual(0); + view.remove(); + + // Verify that the upgrade message takes priority. + data.run_modes[0].upgrade_url = '/path/to/upgrade'; + data.run_modes[0].certificate_url = '/path/to/certificate'; + setupView(data, false); + expect(view.$('.upgrade-message').length).toEqual(1); + expect(view.$('.certificate-status').length).toEqual(0); + }); + + it('should render the course card with coming soon', function(){ + var data = $.extend({}, context); + + data.run_modes[0].is_enrollment_open = false; + setupView(data, false); + expect(view.$('.header-img').attr('src')).toEqual(data.run_modes[0].course_image_url); + expect(view.$('.course-details .course-title').text().trim()).toEqual(data.display_name); expect(view.$('.course-details .course-title-link').length).toBe(0); - expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key); + expect(view.$('.course-details .course-text .course-key').html()).toEqual(data.key); expect(view.$('.course-details .course-text .run-period').length).toBe(0); expect(view.$('.no-action-message').text().trim()).toBe('Coming Soon'); expect(view.$('.enrollment-open-date').text().trim()) - .toEqual(context.run_modes[0].enrollment_open_date); + .toEqual(data.run_modes[0].enrollment_open_date); }); it('should render if enrollment_open_date is not provided', function(){ - view.remove(); - context.run_modes[0].is_enrollment_open = true; - delete context.run_modes[0].enrollment_open_date; - setupView(context, false); + var data = $.extend({}, context); + + data.run_modes[0].is_enrollment_open = true; + delete data.run_modes[0].enrollment_open_date; + setupView(data, false); validateCourseInfoDisplay(); }); }); diff --git a/lms/static/sass/elements/_course-card.scss b/lms/static/sass/elements/_course-card.scss index e6eb95d705..c0138db5c2 100644 --- a/lms/static/sass/elements/_course-card.scss +++ b/lms/static/sass/elements/_course-card.scss @@ -128,6 +128,7 @@ } } + .upgrade-message, .certificate-status { border-top: 1px solid palette(grayscale, x-trans); padding-top: $baseline; diff --git a/lms/templates/learner_dashboard/certificate_status.underscore b/lms/templates/learner_dashboard/certificate_status.underscore index 59cc7ec207..245cd46388 100644 --- a/lms/templates/learner_dashboard/certificate_status.underscore +++ b/lms/templates/learner_dashboard/certificate_status.underscore @@ -1,10 +1,12 @@
- + <% // safe-lint: disable=underscore-not-escaped %> + <%- gettext('Congratulations! You have earned a certificate for this course.') %>
- + <% // safe-lint: disable=underscore-not-escaped %> + <%- gettext('View/Share Certificate') %>
diff --git a/lms/templates/learner_dashboard/course_card.underscore b/lms/templates/learner_dashboard/course_card.underscore index b0b794a23f..e50ec8e8c5 100644 --- a/lms/templates/learner_dashboard/course_card.underscore +++ b/lms/templates/learner_dashboard/course_card.underscore @@ -39,4 +39,5 @@
+
diff --git a/lms/templates/learner_dashboard/upgrade_message.underscore b/lms/templates/learner_dashboard/upgrade_message.underscore new file mode 100644 index 0000000000..2977a1f686 --- /dev/null +++ b/lms/templates/learner_dashboard/upgrade_message.underscore @@ -0,0 +1,12 @@ +
+ <% // safe-lint: disable=underscore-not-escaped %> + + <%- gettext('You need a certificate in this course to be eligible for a program certificate.') %> +
+
+ + <% // safe-lint: disable=underscore-not-escaped %> + + <%- gettext('Upgrade Now') %> + +
diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 71dcc28837..e0f4398db7 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -10,6 +10,7 @@ from django.conf import settings from django.core.cache import cache from django.core.urlresolvers import reverse from django.test import TestCase +from django.test.utils import override_settings from django.utils import timezone import httpretty import mock @@ -18,6 +19,7 @@ from edx_oauth2_provider.tests.factories import ClientFactory from provider.constants import CONFIDENTIAL from lms.djangoapps.certificates.api import MODES +from lms.djangoapps.commerce.tests.test_utils import update_commerce_config from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credentials.tests import factories as credentials_factories from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin @@ -34,6 +36,7 @@ from xmodule.modulestore.tests.factories import CourseFactory UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' CERTIFICATES_API_MODULE = 'lms.djangoapps.certificates.api' +ECOMMERCE_URL_ROOT = 'http://example-ecommerce.com' @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -672,11 +675,14 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): @ddt.ddt +@override_settings(ECOMMERCE_PUBLIC_URL_ROOT=ECOMMERCE_URL_ROOT) @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): """Tests of the utility function used to supplement program data.""" - password = 'test' maxDiff = None + sku = 'abc123' + password = 'test' + checkout_path = '/basket' def setUp(self): super(TestSupplementProgramData, self).setUp() @@ -704,15 +710,18 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): course_overview = CourseOverview.get_from_id(self.course.id) # pylint: disable=no-member run_mode = dict( factories.RunMode( + certificate_url=None, + course_image_url=course_overview.course_image_url, course_key=unicode(self.course.id), # pylint: disable=no-member course_url=reverse('course_root', args=[self.course.id]), # pylint: disable=no-member - course_image_url=course_overview.course_image_url, - start_date=strftime_localized(self.course.start, 'SHORT_DATE'), end_date=strftime_localized(self.course.end, 'SHORT_DATE'), + enrollment_open_date=None, is_course_ended=self.course.end < timezone.now(), is_enrolled=False, is_enrollment_open=True, - marketing_url='', + marketing_url=None, + start_date=strftime_localized(self.course.start, 'SHORT_DATE'), + upgrade_url=None, ), **kwargs ) @@ -722,19 +731,72 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): self.assertEqual(actual, expected) - @ddt.data(True, False) - def test_student_enrollment_status(self, is_enrolled): - """Verify that program data is supplemented correctly.""" + @ddt.data( + (False, None, False), + (True, MODES.audit, True), + (True, MODES.verified, False), + ) + @ddt.unpack + @mock.patch(UTILS_MODULE + '.CourseMode.mode_for_course') + def test_student_enrollment_status(self, is_enrolled, enrolled_mode, is_upgrade_required, mock_get_mode): + """Verify that program data is supplemented with the student's enrollment status.""" + expected_upgrade_url = '{root}/{path}?sku={sku}'.format( + root=ECOMMERCE_URL_ROOT, + path=self.checkout_path.strip('/'), + sku=self.sku, + ) + + update_commerce_config(enabled=True, checkout_page=self.checkout_path) + + mock_mode = mock.Mock() + mock_mode.sku = self.sku + mock_get_mode.return_value = mock_mode + if is_enrolled: - CourseEnrollmentFactory(user=self.user, course_id=self.course.id) # pylint: disable=no-member + CourseEnrollmentFactory(user=self.user, course_id=self.course.id, mode=enrolled_mode) # pylint: disable=no-member data = utils.supplement_program_data(self.program, self.user) - self._assert_supplemented(data, is_enrolled=is_enrolled) + self._assert_supplemented( + data, + is_enrolled=is_enrolled, + upgrade_url=expected_upgrade_url if is_upgrade_required else None + ) + + @ddt.data(MODES.audit, MODES.verified) + def test_inactive_enrollment_no_upgrade(self, enrolled_mode): + """Verify that a student with an inactive enrollment isn't encouraged to upgrade.""" + update_commerce_config(enabled=True, checkout_page=self.checkout_path) + + CourseEnrollmentFactory( + user=self.user, + course_id=self.course.id, # pylint: disable=no-member + mode=enrolled_mode, + is_active=False, + ) + + data = utils.supplement_program_data(self.program, self.user) + + self._assert_supplemented(data) + + @mock.patch(UTILS_MODULE + '.CourseMode.mode_for_course') + def test_ecommerce_disabled(self, mock_get_mode): + """Verify that the utility can operate when the ecommerce service is disabled.""" + update_commerce_config(enabled=False, checkout_page=self.checkout_path) + + mock_mode = mock.Mock() + mock_mode.sku = self.sku + mock_get_mode.return_value = mock_mode + + CourseEnrollmentFactory(user=self.user, course_id=self.course.id, mode=MODES.audit) # pylint: disable=no-member + + data = utils.supplement_program_data(self.program, self.user) + + self._assert_supplemented(data, is_enrolled=True, upgrade_url=None) @ddt.data( - [1, 1, False], - [1, -1, True], + (1, 1, False), + (1, -1, True), ) @ddt.unpack def test_course_enrollment_status(self, start_offset, end_offset, is_enrollment_open): @@ -746,13 +808,15 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): data = utils.supplement_program_data(self.program, self.user) if is_enrollment_open: - self._assert_supplemented(data, is_enrollment_open=is_enrollment_open) + enrollment_open_date = None else: - self._assert_supplemented( - data, - is_enrollment_open=is_enrollment_open, - enrollment_open_date=strftime_localized(self.course.enrollment_start, 'SHORT_DATE') - ) + enrollment_open_date = strftime_localized(self.course.enrollment_start, 'SHORT_DATE') + + self._assert_supplemented( + data, + is_enrollment_open=is_enrollment_open, + enrollment_open_date=enrollment_open_date, + ) @ddt.data(True, False) @mock.patch(UTILS_MODULE + '.certificate_api.certificate_downloadable_status') @@ -765,11 +829,21 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): data = utils.supplement_program_data(self.program, self.user) - if is_uuid_available: - expected_url = reverse('certificates:render_cert_by_uuid', kwargs={'certificate_uuid': test_uuid}) - self._assert_supplemented(data, certificate_url=expected_url) - else: - self._assert_supplemented(data) + expected_url = reverse( + 'certificates:render_cert_by_uuid', + kwargs={'certificate_uuid': test_uuid} + ) if is_uuid_available else None + + self._assert_supplemented(data, certificate_url=expected_url) + + @ddt.data(-1, 0, 1) + def test_course_course_ended(self, days_offset): + self.course.end = timezone.now() + datetime.timedelta(days=days_offset) + self.course = self.update_course(self.course, self.user.id) # pylint: disable=no-member + + data = utils.supplement_program_data(self.program, self.user) + + self._assert_supplemented(data) @mock.patch(UTILS_MODULE + '.get_organization_by_short_name') def test_organization_logo_exists(self, mock_get_organization_by_short_name): @@ -780,6 +854,7 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): mock_get_organization_by_short_name.return_value = { 'logo': mock_image } + data = utils.supplement_program_data(self.program, self.user) self.assertEqual(data['organizations'][0].get('img'), mock_logo_url) @@ -799,11 +874,3 @@ class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): mock_get_organization_by_short_name.return_value = {'logo': None} data = utils.supplement_program_data(self.program, self.user) self.assertEqual(data['organizations'][0].get('img'), None) - - @ddt.data(-1, 0, 1) - def test_course_course_ended(self, days_offset): - self.course.end = timezone.now() + datetime.timedelta(days=days_offset) - self.course = self.update_course(self.course, self.user.id) # pylint: disable=no-member - data = utils.supplement_program_data(self.program, self.user) - - self._assert_supplemented(data) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index cef22bcb5b..2f5006f4c3 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -10,7 +10,9 @@ from django.utils.text import slugify from opaque_keys.edx.keys import CourseKey import pytz +from course_modes.models import CourseMode from lms.djangoapps.certificates import api as certificate_api +from lms.djangoapps.commerce.utils import EcommerceService from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.lib.edx_api_utils import get_edx_api_data @@ -322,6 +324,7 @@ class ProgramProgressMeter(object): return parsed +# TODO: This function will benefit from being refactored as a class. def supplement_program_data(program_data, user): """Supplement program course codes with CourseOverview and CourseEnrollment data. @@ -330,8 +333,8 @@ def supplement_program_data(program_data, user): user (User): The user whose enrollments to inspect. """ for organization in program_data['organizations']: - # TODO cache the results of the get_organization_by_short_name call - # so we don't have to hit database that frequently + # TODO: Cache the results of the get_organization_by_short_name call so + # the database is hit less frequently. org_obj = get_organization_by_short_name(organization['key']) if org_obj and org_obj.get('logo'): organization['img'] = org_obj['logo'].url @@ -341,34 +344,58 @@ def supplement_program_data(program_data, user): course_key = CourseKey.from_string(run_mode['course_key']) course_overview = CourseOverview.get_from_id(course_key) - run_mode['course_url'] = reverse('course_root', args=[course_key]) - run_mode['course_image_url'] = course_overview.course_image_url + course_url = reverse('course_root', args=[course_key]) + course_image_url = course_overview.course_image_url - run_mode['start_date'] = course_overview.start_datetime_text() - run_mode['end_date'] = course_overview.end_datetime_text() + start_date_string = course_overview.start_datetime_text() + end_date_string = course_overview.end_datetime_text() end_date = course_overview.end or datetime.datetime.max.replace(tzinfo=pytz.UTC) - run_mode['is_course_ended'] = end_date < timezone.now() + is_course_ended = end_date < timezone.now() - run_mode['is_enrolled'] = CourseEnrollment.is_enrolled(user, course_key) + is_enrolled = CourseEnrollment.is_enrolled(user, course_key) enrollment_start = course_overview.enrollment_start or datetime.datetime.min.replace(tzinfo=pytz.UTC) enrollment_end = course_overview.enrollment_end or datetime.datetime.max.replace(tzinfo=pytz.UTC) is_enrollment_open = enrollment_start <= timezone.now() < enrollment_end - run_mode['is_enrollment_open'] = is_enrollment_open - if not is_enrollment_open: - # Only render this enrollment open date if the enrollment open is in the future - run_mode['enrollment_open_date'] = strftime_localized(enrollment_start, 'SHORT_DATE') - # TODO: Currently unavailable on LMS. - run_mode['marketing_url'] = '' + enrollment_open_date = None if is_enrollment_open else strftime_localized(enrollment_start, 'SHORT_DATE') certificate_data = certificate_api.certificate_downloadable_status(user, course_key) certificate_uuid = certificate_data.get('uuid') - if certificate_uuid: - run_mode['certificate_url'] = certificate_api.get_certificate_url( - course_id=course_key, - uuid=certificate_uuid, - ) + certificate_url = certificate_api.get_certificate_url( + course_id=course_key, + uuid=certificate_uuid, + ) if certificate_uuid else None + + required_mode_slug = run_mode['mode_slug'] + enrolled_mode_slug, _ = CourseEnrollment.enrollment_mode_for_user(user, course_key) + is_mode_mismatch = required_mode_slug != enrolled_mode_slug + is_upgrade_required = is_enrolled and is_mode_mismatch + + # Requires that the ecommerce service be in use. + required_mode = CourseMode.mode_for_course(course_key, required_mode_slug) + ecommerce = EcommerceService() + sku = getattr(required_mode, 'sku', None) + + if ecommerce.is_enabled(user) and sku: + upgrade_url = ecommerce.checkout_page_url(required_mode.sku) if is_upgrade_required else None + else: + upgrade_url = None + + run_mode.update({ + 'certificate_url': certificate_url, + 'course_image_url': course_image_url, + 'course_url': course_url, + 'end_date': end_date_string, + 'enrollment_open_date': enrollment_open_date, + 'is_course_ended': is_course_ended, + 'is_enrolled': is_enrolled, + 'is_enrollment_open': is_enrollment_open, + # TODO: Not currently available on LMS. + 'marketing_url': None, + 'start_date': start_date_string, + 'upgrade_url': upgrade_url, + }) return program_data