diff --git a/common/djangoapps/student/tests/test_verification_status.py b/common/djangoapps/student/tests/test_verification_status.py index 08093f24486d8052c4c46ebf27ac85c23841b9f3..f320be7aa23dd53452c5ea888a09ad2ab15715c6 100644 --- a/common/djangoapps/student/tests/test_verification_status.py +++ b/common/djangoapps/student/tests/test_verification_status.py @@ -371,8 +371,7 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): # Verify that the correct banner color is rendered self.assertContains( response, - "<div class=\"course {}\" aria-labelledby=\"course-title-{}\">".format( - self.MODE_CLASSES[status], self.course.id) + "<article class=\"course {}\"".format(self.MODE_CLASSES[status]) ) # Verify that the correct copy is rendered on the dashboard diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index a44664feda2074d0098be3a38f5bef5e53e64253..804200fa2364b0fe6c1fa432569223c09ca7a9ae 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -58,6 +58,7 @@ from bulk_email.models import BulkEmailFlag, Optout # pylint: disable=import-er from certificates.api import get_certificate_url, has_html_certificates_enabled # pylint: disable=import-error from certificates.models import ( # pylint: disable=import-error CertificateStatuses, + GeneratedCertificate, certificate_status_for_student ) from course_modes.models import CourseMode @@ -65,6 +66,7 @@ from courseware.access import has_access from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error from django_comment_common.models import assign_role from edxmako.shortcuts import render_to_response, render_to_string +from entitlements.models import CourseEntitlement from eventtracking import tracker from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory @@ -72,7 +74,7 @@ from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # Note that this lives in LMS, so this dependency should be refactored. from notification_prefs.views import enable_notifications from openedx.core.djangoapps import monitoring_utils -from openedx.core.djangoapps.catalog.utils import get_programs_with_type +from openedx.core.djangoapps.catalog.utils import get_programs_with_type, get_course_runs_for_course from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings from openedx.core.djangoapps.embargo import api as embargo_api @@ -686,15 +688,22 @@ def dashboard(request): 'ACTIVATION_EMAIL_SUPPORT_LINK', settings.ACTIVATION_EMAIL_SUPPORT_LINK ) or settings.SUPPORT_SITE_LINK - # get the org whitelist or the org blacklist for the current site + # Get the org whitelist or the org blacklist for the current site site_org_whitelist, site_org_blacklist = get_org_black_and_whitelist_for_site(user) course_enrollments = list(get_course_enrollments(user, site_org_whitelist, site_org_blacklist)) + # Get the entitlements for the user and a mapping to all available sessions for that entitlement + course_entitlements = list(CourseEntitlement.objects.filter(user=user).select_related('enrollment_course_run')) + course_entitlement_available_sessions = { + str(entitlement.uuid): get_course_runs_for_course(str(entitlement.course_uuid)) + for entitlement in course_entitlements + } + # Record how many courses there are so that we can get a better # understanding of usage patterns on prod. monitoring_utils.accumulate('num_courses', len(course_enrollments)) - # sort the enrollment pairs by the enrollment date + # Sort the enrollment pairs by the enrollment date course_enrollments.sort(key=lambda x: x.created, reverse=True) # Retrieve the course modes for each course @@ -863,6 +872,10 @@ def dashboard(request): valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired'] display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses + # Filter out any course enrollment course cards that are associated with fulfilled entitlements + for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]: + course_enrollments = [enr for enr in course_enrollments if entitlement.enrollment_course_run.course_id != enr.course_id] # pylint: disable=line-too-long + context = { 'enterprise_message': enterprise_message, 'consent_required_courses': consent_required_courses, @@ -871,6 +884,8 @@ def dashboard(request): 'redirect_message': redirect_message, 'account_activation_messages': account_activation_messages, 'course_enrollments': course_enrollments, + 'course_entitlements': course_entitlements, + 'course_entitlement_available_sessions': course_entitlement_available_sessions, 'course_optouts': course_optouts, 'banner_account_activation_message': banner_account_activation_message, 'sidebar_account_activation_message': sidebar_account_activation_message, diff --git a/lms/static/js/dashboard/dropdown.js b/lms/static/js/dashboard/dropdown.js index d05b90d7af2baab811a17ee126f9987cdcac8ea2..dc67c44b84abb1ecb687975105397edec48cca0f 100644 --- a/lms/static/js/dashboard/dropdown.js +++ b/lms/static/js/dashboard/dropdown.js @@ -10,10 +10,10 @@ var edx = edx || {}; edx.dashboard.dropdown.toggleCourseActionsDropdownMenu = function(event) { // define variables for code legibility var dashboardIndex = $(event.currentTarget).data().dashboardIndex, - dropdown = $('#actions-dropdown-' + dashboardIndex), + $dropdown = $('#actions-dropdown-' + dashboardIndex), dropdownButton = $('#actions-dropdown-link-' + dashboardIndex), ariaExpandedState = (dropdownButton.attr('aria-expanded') === 'true'), - menuItems = dropdown.find('a'); + menuItems = $dropdown.find('a'); var catchKeyPress = function(object, event) { // get currently focused item @@ -57,12 +57,12 @@ var edx = edx || {}; }; // Toggle the visibility control for the selected element and set the focus - dropdown.toggleClass('is-visible'); - if (dropdown.hasClass('is-visible')) { - dropdown.attr('tabindex', -1); - dropdown.focus(); + $dropdown.toggleClass('is-visible'); + if ($dropdown.hasClass('is-visible')) { + $dropdown.attr('tabindex', -1); + $dropdown.focus(); } else { - dropdown.removeAttr('tabindex'); + $dropdown.removeAttr('tabindex'); dropdownButton.focus(); } @@ -71,8 +71,8 @@ var edx = edx || {}; // catch keypresses when inside dropdownMenu (we want to catch spacebar; // escape; up arrow or shift+tab; and down arrow or tab) - dropdown.on('keydown', function(event) { - catchKeyPress($(this), event); + $dropdown.on('keydown', function(e) { + catchKeyPress($(this), e); }); }; diff --git a/lms/static/js/learner_dashboard/course_entitlement_factory.js b/lms/static/js/learner_dashboard/course_entitlement_factory.js new file mode 100644 index 0000000000000000000000000000000000000000..50b18841d23cd613c2a346ab349da29b9faba699 --- /dev/null +++ b/lms/static/js/learner_dashboard/course_entitlement_factory.js @@ -0,0 +1,12 @@ +(function(define) { + 'use strict'; + + define([ + 'js/learner_dashboard/views/course_entitlement_view' + ], + function(EntitlementView) { + return function(options) { + return new EntitlementView(options); + }; + }); +}).call(this, define || RequireJS.define); 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 d4cf89911ca32363c2118587c828b9aea4db26d5..5d86806b83072ebe0b662f04e8768286ca1448fa 100644 --- a/lms/static/js/learner_dashboard/models/course_card_model.js +++ b/lms/static/js/learner_dashboard/models/course_card_model.js @@ -6,10 +6,12 @@ define([ 'backbone', 'underscore', + 'gettext', 'jquery', - 'edx-ui-toolkit/js/utils/date-utils' + 'edx-ui-toolkit/js/utils/date-utils', + 'edx-ui-toolkit/js/utils/string-utils' ], - function(Backbone, _, $, DateUtils) { + function(Backbone, _, gettext, $, DateUtils, StringUtils) { return Backbone.Model.extend({ initialize: function(data) { if (data) { @@ -140,7 +142,7 @@ formatDateString: function(run) { var pacingType = run.pacing_type, - dateString = '', + dateString, start = this.get('start_date') || run.start_date, end = this.get('end_date') || run.end_date, now = new Date(), @@ -148,21 +150,24 @@ endDate = new Date(end); if (pacingType === 'self_paced') { - dateString = 'Self-paced'; - if (start && startDate > now) { - dateString += ' - Starts ' + start; + if (start) { + dateString = startDate > now ? + StringUtils.interpolate(gettext('(Self-paced) Starts {start}'), {start: start}) : + StringUtils.interpolate(gettext('(Self-paced) Started {start}'), {start: start}); } else if (end && endDate > now) { - dateString += ' - Ends ' + end; + dateString = StringUtils.interpolate(gettext('(Self-paced) Ends {end}'), {end: end}); } else if (end && endDate < now) { - dateString += ' - Ended ' + end; + dateString = StringUtils.interpolate(gettext('(Self-paced) Ended {end}'), {end: end}); } } else { if (start && end) { dateString = start + ' - ' + end; } else if (start) { - dateString = 'Starts ' + start; + dateString = startDate > now ? + StringUtils.interpolate(gettext('Starts {start}'), {start: start}) : + StringUtils.interpolate(gettext('Started {start}'), {start: start}); } else if (end) { - dateString = 'Ends ' + end; + dateString = StringUtils.interpolate(gettext('Ends {end}'), {end: end}); } } return dateString; diff --git a/lms/static/js/learner_dashboard/models/course_entitlement_model.js b/lms/static/js/learner_dashboard/models/course_entitlement_model.js new file mode 100644 index 0000000000000000000000000000000000000000..ebd666822dd18cef31f554415e1922d77d19373c --- /dev/null +++ b/lms/static/js/learner_dashboard/models/course_entitlement_model.js @@ -0,0 +1,22 @@ +/** + * Store data for the current + */ +(function(define) { + 'use strict'; + + define([ + 'backbone' + ], + function(Backbone) { + return Backbone.Model.extend({ + defaults: { + availableSessions: [], + entitlementUUID: '', + currentSessionId: '', + userId: '', + courseName: '' + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/views/course_entitlement_view.js b/lms/static/js/learner_dashboard/views/course_entitlement_view.js new file mode 100644 index 0000000000000000000000000000000000000000..cf974973b956415a2d5bb20b9fa7e0d1d123f86e --- /dev/null +++ b/lms/static/js/learner_dashboard/views/course_entitlement_view.js @@ -0,0 +1,399 @@ +// This is required for karma testing due to a known issue in Bootstrap-v4: https://github.com/twbs/bootstrap/pull/22888 +// The issue is that bootstrap tries to access Popper's global Popper object which is not initialized on loading +// from the karma configuration. The next version of bootstrap (>v4.2) will solve this issue. +// Once this is resolved, we should import bootstrap through require-config.js and main.js (for jasmine testing) +var defineFn = require || RequireJS.require; // eslint-disable-line global-require +var Popper = defineFn(['common/js/vendor/popper']); +defineFn(['common/js/vendor/bootstrap']); + +(function(define) { + 'use strict'; + + define(['backbone', + 'jquery', + 'underscore', + 'gettext', + 'moment', + 'edx-ui-toolkit/js/utils/html-utils', + 'js/learner_dashboard/models/course_entitlement_model', + 'js/learner_dashboard/models/course_card_model', + 'text!../../../templates/learner_dashboard/course_entitlement.underscore', + 'text!../../../templates/learner_dashboard/verification_popover.underscore' + ], + function( + Backbone, + $, + _, + gettext, + moment, + HtmlUtils, + EntitlementModel, + CourseCardModel, + pageTpl, + verificationPopoverTpl + ) { + return Backbone.View.extend({ + tpl: HtmlUtils.template(pageTpl), + verificationTpl: HtmlUtils.template(verificationPopoverTpl), + + events: { + 'change .session-select': 'updateEnrollBtn', + 'click .enroll-btn': 'handleEnrollChange', + 'keydown .final-confirmation-btn': 'handleVerificationPopoverA11y', + 'click .popover-dismiss': 'hideDialog' + }, + + initialize: function(options) { + // Set up models and reload view on change + this.courseCardModel = new CourseCardModel(); + this.entitlementModel = new EntitlementModel({ + availableSessions: this.formatDates(JSON.parse(options.availableSessions)), + entitlementUUID: options.entitlementUUID, + currentSessionId: options.currentSessionId, + userId: options.userId, + courseName: options.courseName + }); + this.listenTo(this.entitlementModel, 'change', this.render); + + // Grab URLs that handle changing of enrollment and entering a newly selected session. + this.enrollUrl = options.enrollUrl; + this.courseHomeUrl = options.courseHomeUrl; + + // Grab elements from the parent card that work with this view and bind associated events + this.$triggerOpenBtn = $(options.triggerOpenBtn); // Opens/closes session selection view + this.$dateDisplayField = $(options.dateDisplayField); // Displays current session dates + this.$enterCourseBtn = $(options.enterCourseBtn); // Button link to course home page + this.$courseCardMessages = $(options.courseCardMessages); // Additional session messages + this.$courseTitleLink = $(options.courseTitleLink); // Title link to course home page + this.$courseImageLink = $(options.courseImageLink); // Image link to course home page + this.$triggerOpenBtn.on('click', this.toggleSessionSelectionPanel.bind(this)); + + this.render(options); + this.postRender(); + }, + + render: function() { + HtmlUtils.setHtml(this.$el, this.tpl(this.entitlementModel.toJSON())); + this.delegateEvents(); + this.updateEnrollBtn(); + return this; + }, + + postRender: function() { + // Close popover on click-away + $(document).on('click', function(e) { + if (!($(e.target).closest('.enroll-btn-initial, .popover').length)) { + this.hideDialog(this.$('.enroll-btn-initial')); + } + }.bind(this)); + + this.$('.enroll-btn-initial').click(function(e) { + this.showDialog($(e.target)); + }.bind(this)); + }, + + handleEnrollChange: function() { + /* + Handles enrolling in a course, unenrolling in a session and changing session. + The new session id is stored as a data attribute on the option in the session-select element. + */ + var isLeavingSession; + + // Do not allow for enrollment when button is disabled + if (this.$('.enroll-btn-initial').hasClass('disabled')) return; + + // Grab the id for the desired session, an leave session event will return null + this.currentSessionSelection = this.$('.session-select') + .find('option:selected').data('session_id'); + isLeavingSession = !this.currentSessionSelection; + + // Display the indicator icon + HtmlUtils.setHtml(this.$dateDisplayField, + HtmlUtils.HTML('<span class="fa fa-spinner fa-spin" aria-hidden="true"></span>') + ); + + $.ajax({ + type: isLeavingSession ? 'DELETE' : 'POST', + url: this.enrollUrl, + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + course_run_id: this.currentSessionSelection + }), + statusCode: { + 201: _.bind(this.enrollSuccess, this), + 204: _.bind(this.unenrollSuccess, this) + }, + error: _.bind(this.enrollError, this) + }); + }, + + enrollSuccess: function(data) { + /* + Update external elements on the course card to represent the now available course session. + + 1) Show the change session toggle button. + 2) Add the new session's dates to the date field on the main course card. + 3) Hide the 'View Course' button to the course card. + */ + var successIconEl = '<span class="fa fa-check" aria-hidden="true"></span>'; + + // Update the model with the new session Id; + this.entitlementModel.set({currentSessionId: this.currentSessionSelection}); + + // Allow user to change session + this.$triggerOpenBtn.removeClass('hidden'); + + // Display a success indicator + HtmlUtils.setHtml(this.$dateDisplayField, + HtmlUtils.joinHtml( + HtmlUtils.HTML(successIconEl), + this.getAvailableSessionWithId(data.course_run_id).session_dates + ) + ); + + // Ensure the view course button links to new session home page and place focus there + this.$enterCourseBtn + .attr('href', this.formatCourseHomeUrl(data.course_run_id)) + .removeClass('hidden') + .focus(); + this.toggleSessionSelectionPanel(); + }, + + unenrollSuccess: function() { + /* + Update external elements on the course card to represent the unenrolled state. + + 1) Hide the change session button and the date field. + 2) Hide the 'View Course' button. + 3) Remove the messages associated with the enrolled state. + 4) Remove the link from the course card image and title. + */ + + // Update the model with the new session Id; + this.entitlementModel.set({currentSessionId: this.currentSessionSelection}); + + // Reset the card contents to the unenrolled state + this.$triggerOpenBtn.addClass('hidden'); + this.$enterCourseBtn.addClass('hidden'); + this.$courseCardMessages.remove(); + this.$('.enroll-btn-initial').focus(); + HtmlUtils.setHtml( + this.$dateDisplayField, + HtmlUtils.joinHtml( + HtmlUtils.HTML('<span class="icon fa fa-warning" aria-hidden="true"></span>'), + HtmlUtils.HTML(gettext('You must select a session to access the course.')) + ) + ); + + // Remove links to previously enrolled sessions + this.$courseImageLink.replaceWith( // xss-lint: disable=javascript-jquery-insertion + HtmlUtils.joinHtml( + HtmlUtils.HTML('<div class="'), + this.$courseImageLink.attr('class'), + HtmlUtils.HTML('" tabindex="-1">'), + HtmlUtils.HTML(this.$courseImageLink.html()), + HtmlUtils.HTML('</div>') + ).text + ); + this.$courseTitleLink.replaceWith( // xss-lint: disable=javascript-jquery-insertion + HtmlUtils.joinHtml( + HtmlUtils.HTML('<span>'), + this.$courseTitleLink.text(), + HtmlUtils.HTML('</span>') + ).text + ); + }, + + enrollError: function() { + var errorMsgEl = HtmlUtils.HTML( + gettext('There was an error. Please reload the page and try again.') + ).text; + this.$dateDisplayField + .find('.fa.fa-spin') + .removeClass('fa-spin fa-spinner') + .addClass('fa-close'); + this.$dateDisplayField.append(errorMsgEl); + this.hideDialog(this.$('.enroll-btn-initial')); + }, + + updateEnrollBtn: function() { + /* + This function is invoked on load, on opening the view and on changing the option on the session + selection dropdown. It plays three roles: + 1) Enables and disables enroll button + 2) Changes text to describe the action taken + 3) Formats the confirmation popover to allow for two step authentication + */ + var enrollText, + currentSessionId = this.entitlementModel.get('currentSessionId'), + newSessionId = this.$('.session-select').find('option:selected').data('session_id'), + enrollBtnInitial = this.$('.enroll-btn-initial'); + + // Disable the button if the user is already enrolled in that session. + if (currentSessionId === newSessionId) { + enrollBtnInitial.addClass('disabled'); + this.removeDialog(enrollBtnInitial); + return; + } + enrollBtnInitial.removeClass('disabled'); + + // Update button text specifying if the user is initially enrolling, changing or leaving a session. + if (newSessionId) { + enrollText = currentSessionId ? gettext('Change Session') : gettext('Select Session'); + } else { + enrollText = gettext('Leave Current Session'); + } + enrollBtnInitial.text(enrollText); + this.removeDialog(enrollBtnInitial); + this.initializeVerificationDialog(enrollBtnInitial); + }, + + toggleSessionSelectionPanel: function() { + /* + Opens and closes the session selection panel. + */ + this.$el.toggleClass('hidden'); + if (!this.$el.hasClass('hidden')) { + // Set focus to the session selection for a11y purposes + this.$('.session-select').focus(); + this.hideDialog(this.$('.enroll-btn-initial')); + } + this.updateEnrollBtn(); + }, + + initializeVerificationDialog: function(invokingElement) { + /* + Instantiates an instance of the Bootstrap v4 dialog modal and attaches it to the passed in element. + + This dialog acts as the second step in verifying the user's action to select, change or leave an + available course session. + */ + var confirmationMsgTitle, + confirmationMsgBody, + popoverDialogHtml, + currentSessionId = this.entitlementModel.get('currentSessionId'), + newSessionId = this.$('.session-select').find('option:selected').data('session_id'); + + // Update the button popover text to enable two step authentication. + if (newSessionId) { + confirmationMsgTitle = !currentSessionId ? + gettext('Are you sure you want to select this session?') : + gettext('Are you sure you want to change to a different session?'); + confirmationMsgBody = !currentSessionId ? '' : + gettext('Any course progress or grades from your current session will be lost.'); + } else { + confirmationMsgTitle = gettext('Are you sure that you want to leave this session?'); + confirmationMsgBody = gettext('Any course progress or grades from your current session will be lost.'); // eslint-disable-line max-len + } + + // Remove existing popover and re-initialize + popoverDialogHtml = this.verificationTpl({ + confirmationMsgTitle: confirmationMsgTitle, + confirmationMsgBody: confirmationMsgBody + }); + + invokingElement.popover({ + placement: 'bottom', + container: this.$el, + html: true, + trigger: 'click', + content: popoverDialogHtml.text + }); + }, + + removeDialog: function(invokingElement) { + /* Removes the Bootstrap v4 dialog modal from the update session enrollment button. */ + invokingElement.popover('dispose'); + }, + + showDialog: function(invokingElement) { + /* Given an element with an associated dialog modal, shows the modal. */ + invokingElement.popover('show'); + this.$('.final-confirmation-btn:first').focus(); + }, + + hideDialog: function(el, returnFocus) { + /* Hides the modal without removing it from the DOM. */ + var $el = el instanceof jQuery ? el : this.$('.enroll-btn-initial'); + $el.popover('hide'); + if (returnFocus) { + $el.focus(); + } + }, + + handleVerificationPopoverA11y: function(e) { + /* Ensure that the second step verification popover is treated as an a11y compliant dialog */ + var $nextButton, + $verificationOption = $(e.target), + openButton = $(e.target).closest('.course-entitlement-selection-container') + .find('.enroll-btn-initial'); + if (e.key === 'Tab') { + e.preventDefault(); + $nextButton = $verificationOption.is(':first-child') ? + $verificationOption.next('.final-confirmation-btn') : + $verificationOption.prev('.final-confirmation-btn'); + $nextButton.focus(); + } else if (e.key === 'Escape') { + this.hideDialog(openButton); + openButton.focus(); + } + }, + + formatCourseHomeUrl: function(sessionKey) { + /* + Takes the base course home URL and updates it with the new session id, leveraging the + the fact that all course keys contain a '+' symbol. + */ + var oldSessionKey = this.courseHomeUrl.split('/') + .filter( + function(urlParam) { + return urlParam.indexOf('+') > 0; + } + )[0]; + return this.courseHomeUrl.replace(oldSessionKey, sessionKey); + }, + + formatDates: function(sessionData) { + /* + Takes a data object containing the upcoming available sessions for an entitlement and returns + the object with a session_dates attribute representing a formatted date string that highlights + the start and end dates of the particular session. + */ + var formattedSessionData = sessionData, + startDate, + endDate, + dateFormat; + // Set the date format string to the user's selected language + moment.locale(document.documentElement.lang); + dateFormat = moment.localeData().longDateFormat('L').indexOf('DD') > + moment.localeData().longDateFormat('L').indexOf('MM') ? 'MMMM D, YYYY' : 'D MMMM, YYYY'; + + return _.map(formattedSessionData, function(session) { + var formattedSession = session; + startDate = this.formatDate(formattedSession.session_start, dateFormat); + endDate = this.formatDate(formattedSession.session_end, dateFormat); + formattedSession.enrollment_end = this.formatDate(formattedSession.enrollment_end, dateFormat); + formattedSession.session_dates = this.courseCardModel.formatDateString({ + start_date: session.session_start_advertised || startDate, + end_date: session.session_start_advertised ? null : endDate, + pacing_type: formattedSession.pacing_type + }); + return formattedSession; + }, this); + }, + + formatDate: function(date, dateFormat) { + return date ? moment((new Date(date))).format(dateFormat) : null; + }, + + getAvailableSessionWithId: function(sessionId) { + /* Returns an available session given a sessionId */ + return this.entitlementModel.get('availableSessions').find(function(session) { + return session.session_id === sessionId; + }); + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js b/lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..934613d9292f25f0f28a749b78e0fee9f133e5a4 --- /dev/null +++ b/lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js @@ -0,0 +1,158 @@ +define([ + 'backbone', + 'underscore', + 'jquery', + 'js/learner_dashboard/models/course_entitlement_model', + 'js/learner_dashboard/views/course_entitlement_view' +], function(Backbone, _, $, CourseEntitlementModel, CourseEntitlementView) { + 'use strict'; + + describe('Course Entitlement View', function() { + var view = null, + setupView, + selectOptions, + entitlementAvailableSessions, + initialSessionId, + entitlementUUID = 'a9aiuw76a4ijs43u18', + testSessionIds = ['test_session_id_1', 'test_session_id_2']; + + setupView = function(isAlreadyEnrolled) { + setFixtures('<div class="course-entitlement-selection-container"></div>'); + + initialSessionId = isAlreadyEnrolled ? testSessionIds[0] : ''; + entitlementAvailableSessions = [{ + enrollment_end: null, + session_start: '2013-02-05T05:00:00+00:00', + pacing_type: 'instructor_paced', + session_id: testSessionIds[0], + session_end: null + }, { + enrollment_end: '2017-12-22T03:30:00Z', + session_start: '2018-01-03T13:00:00+00:00', + pacing_type: 'self_paced', + session_id: testSessionIds[1], + session_end: '2018-03-09T21:30:00+00:00' + }]; + + view = new CourseEntitlementView({ + el: '.course-entitlement-selection-container', + triggerOpenBtn: '#course-card-0 .change-session', + courseCardMessages: '#course-card-0 .messages-list > .message', + courseTitleLink: '#course-card-0 .course-title a', + courseImageLink: '#course-card-0 .wrapper-course-image > a', + dateDisplayField: '#course-card-0 .info-date-block', + enterCourseBtn: '#course-card-0 .enter-course', + availableSessions: JSON.stringify(entitlementAvailableSessions), + entitlementUUID: entitlementUUID, + currentSessionId: initialSessionId, + userId: '1', + enrollUrl: '/api/enrollment/v1/enrollment', + courseHomeUrl: '/courses/course-v1:edX+DemoX+Demo_Course/course/' + }); + }; + + afterEach(function() { + if (view) view.remove(); + }); + + describe('Initialization of view', function() { + it('Should create a entitlement view element', function() { + setupView(false); + expect(view).toBeDefined(); + }); + }); + + describe('Available Sessions Select - Unfulfilled Entitlement', function() { + beforeEach(function() { + setupView(false); + selectOptions = view.$('.session-select').find('option'); + }); + + it('Select session dropdown should show all available course runs and a coming soon option.', function() { + expect(selectOptions.length).toEqual(entitlementAvailableSessions.length + 1); + }); + + it('Self paced courses should have visual indication in the selection option.', function() { + var selfPacedOptionIndex = _.findIndex(entitlementAvailableSessions, function(session) { + return session.pacing_type === 'self_paced'; + }); + var selfPacedOption = selectOptions[selfPacedOptionIndex]; + expect(selfPacedOption && selfPacedOption.text.includes('(Self-paced)')).toBe(true); + }); + + it('Courses with an an enroll by date should indicate so on the selection option.', function() { + var enrollEndSetOptionIndex = _.findIndex(entitlementAvailableSessions, function(session) { + return session.enrollment_end !== null; + }); + var enrollEndSetOption = selectOptions[enrollEndSetOptionIndex]; + expect(enrollEndSetOption && enrollEndSetOption.text.includes('Open until')).toBe(true); + }); + + it('Title element should correctly indicate the expected behavior.', function() { + expect(view.$('.action-header').text().includes( + 'To access the course, select a session.' + )).toBe(true); + }); + }); + + describe('Available Sessions Select - Fulfilled Entitlement', function() { + beforeEach(function() { + setupView(true); + selectOptions = view.$('.session-select').find('option'); + }); + + it('Select session dropdown should show available course runs, coming soon and leave options.', function() { + expect(selectOptions.length).toEqual(entitlementAvailableSessions.length + 2); + }); + + it('Select session dropdown should allow user to leave the current session.', function() { + var leaveSessionOption = selectOptions[selectOptions.length - 1]; + expect(leaveSessionOption.text.includes('Leave the current session and decide later')).toBe(true); + }); + + it('Currently selected session should be specified in the dropdown options.', function() { + var selectedSessionIndex = _.findIndex(entitlementAvailableSessions, function(session) { + return initialSessionId === session.session_id; + }); + expect(selectOptions[selectedSessionIndex].text.includes('Currently Selected')).toBe(true); + }); + + it('Title element should correctly indicate the expected behavior.', function() { + expect(view.$('.action-header').text().includes( + 'Change to a different session or leave the current session.' + )).toBe(true); + }); + }); + + describe('Select Session Action Button and popover behavior - Unfulfilled Entitlement', function() { + beforeEach(function() { + setupView(false); + }); + + it('Change session button should have the correct text.', function() { + expect(view.$('.enroll-btn-initial').text() === 'Select Session').toBe(true); + }); + + it('Select session button should show popover when clicked.', function() { + view.$('.enroll-btn-initial').click(); + expect(view.$('.verification-modal').length > 0).toBe(true); + }); + }); + + describe('Change Session Action Button and popover behavior - Fulfilled Entitlement', function() { + beforeEach(function() { + setupView(true); + selectOptions = view.$('.session-select').find('option'); + }); + + it('Change session button should show correct text.', function() { + expect(view.$('.enroll-btn-initial').text().trim() === 'Change Session').toBe(true); + }); + + it('Switch session button should be disabled when on the currently enrolled session.', function() { + expect(view.$('.enroll-btn-initial')).toHaveClass('disabled'); + }); + }); + }); +} +); diff --git a/lms/static/lms/js/require-config.js b/lms/static/lms/js/require-config.js index ba785e1d51300c29e1f62de9e6ff309367d6277f..c438c2137c98810a28e7c5dfd582e0cc0fbe8ee8 100644 --- a/lms/static/lms/js/require-config.js +++ b/lms/static/lms/js/require-config.js @@ -100,6 +100,8 @@ 'string_utils': 'js/src/string_utils', 'utility': 'js/src/utility', 'draggabilly': 'js/vendor/draggabilly', + 'popper': 'common/js/vendor/popper', + 'bootstrap': 'common/js/vendor/bootstrap', // Files needed by OVA 'annotator': 'js/vendor/ova/annotator-full', @@ -206,6 +208,13 @@ 'grouping-annotator': { deps: ['annotator'] }, + 'popper': { + exports: 'Popper' + }, + 'bootstrap': { + deps: ['jquery', 'popper'], + exports: 'bootstrap' + }, 'ova': { exports: 'ova', deps: [ diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 0ac5f80d62d54ba365e6622f978f9f9dc7e0b36f..f52a2085657b8941e5744e8a9787e0e77cbca912 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -762,6 +762,7 @@ 'js/spec/learner_dashboard/unenroll_view_spec.js', 'js/spec/learner_dashboard/course_card_view_spec.js', 'js/spec/learner_dashboard/course_enroll_view_spec.js', + 'js/spec/learner_dashboard/course_entitlement_view_spec.js', 'js/spec/markdown_editor_spec.js', 'js/spec/dateutil_factory_spec.js', 'js/spec/navigation_spec.js', diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index b6e0b51151058a978125ae43d8c5afd69993a55d..6ee1904ea6feb8bf8124e94cdaf2f7848c9a5ad4 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -163,11 +163,31 @@ display: inline-block; } - .info-date-block { - @extend %t-title7; - - color: $gray; // WCAG 2.0 AA compliant + .info-date-block-container { display: block; + + .info-date-block{ + @extend %t-title7; + + color: $gray; // WCAG 2.0 AA compliant + + .fa-close { + color: theme-color("error"); + } + + .fa-check { + color: theme-color("success"); + } + } + + .change-session { + @extend %t-title7; + @include margin(0, 0, 0, $baseline/4); + + padding: 0; + border: none; + letter-spacing: normal; + } } } @@ -633,18 +653,20 @@ .message-copy .copy { @extend %t-copy-sub1; - margin: 2px 0 0 0; + margin: 2px 0 0; } // CASE: expandable &.is-expandable { .wrapper-tip { - .message-title, .message-copy { + .message-title, + .message-copy { margin-bottom: 0; display: inline-block; } - .message-title .value, .message-copy { + .message-title .value, + .message-copy { @include transition(color $tmg-f2 ease-in-out 0s); } @@ -652,7 +674,9 @@ &:hover { cursor: pointer; - .message-title .value, .message-copy, .ui-toggle-expansion { + .message-title .value, + .message-copy, + .ui-toggle-expansion { color: $link-color; } } @@ -789,7 +813,7 @@ .action-view-consent { @extend %btn-pl-white-base; @include float(right); - + &.archived { @extend %btn-pl-default-base; } @@ -1071,6 +1095,89 @@ @include padding($baseline/2, $baseline, $baseline/2, $baseline/2); } } + + // Course Entitlement Session Selection + .course-entitlement-selection-container { + background-color: theme-color("inverse"); + + .action-header { + padding-bottom: $baseline/4; + font-weight: $font-weight-bold; + color: theme-color("dark"); + } + + .action-controls { + display: flex; + + .session-select { + background-color: theme-color("inverse"); + height: $baseline*1.5; + flex-grow: 5; + margin-bottom: $baseline*0.4; + } + + .enroll-btn-initial { + @include margin-left($baseline); + + height: $baseline*1.5; + flex-grow: 1; + letter-spacing: 0; + background: theme-color("inverse"); + border-color: theme-color("primary"); + color: theme-color("primary"); + text-shadow: none; + font-size: $font-size-base; + padding: 0; + box-shadow: none; + border-radius: $border-radius-sm; + transition: all 0.4s ease-out; + + &:hover { + background: theme-color("primary"); + border-color: theme-color("primary"); + color: theme-color("inverse"); + } + } + + @include media-breakpoint-down(xs) { + flex-direction: column; + + .enroll-btn-initial { + margin: $baseline/4 0 $baseline/4; + } + } + } + + .popover { + .popover-title { + margin-bottom: $baseline/2; + } + + .action-items { + display: flex; + justify-content: space-between; + margin-top: $baseline/2; + + .final-confirmation-btn { + box-shadow: none; + border: 1px solid theme-color("dark"); + background: none; + color: theme-color("dark"); + text-shadow: none; + letter-spacing: 0; + flex-grow: 1; + margin: 0 $baseline/4; + padding: $baseline/10 $baseline; + font-size: $font-size-base; + + &:hover { + background: theme-color("primary"); + color: theme-color("inverse"); + } + } + } + } + } } // CASE: empty dashboard @@ -1323,11 +1430,11 @@ p.course-block { padding: 6px 32px 7px; text-align: center; margin-top: 16px; - opacity:0.5; - background:#808080; - border:0; + opacity: 0.5; + background: #808080; + border: 0; color: theme-color("inverse"); - box-shadow:none; + box-shadow: none; &.archived { @include button(simple, $button-archive-color); @@ -1557,5 +1664,4 @@ a.fade-cover { color: theme-color("inverse"); text-decoration: none; } - } diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index b70271f6bf8a15a17e437e68810a97f7a30144c6..c60337be24966dbdd1edf1eda928c03f95ca504a 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -6,11 +6,16 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from django.template import RequestContext +from entitlements.models import CourseEntitlement import third_party_auth from third_party_auth import pipeline +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML, Text + +from student.models import CourseEnrollment %> <% @@ -108,7 +113,7 @@ from openedx.core.djangolib.markup import HTML, Text <div class="my-courses" id="my-courses"> <%include file="learner_dashboard/_dashboard_navigation_courses.html"/> - % if len(course_enrollments) > 0: + % if len(course_entitlements + course_enrollments) > 0: <ul class="listing-courses"> <% share_settings = configuration_helpers.get_value( @@ -116,20 +121,53 @@ from openedx.core.djangolib.markup import HTML, Text getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}) ) %> - % for dashboard_index, enrollment in enumerate(course_enrollments): - <% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %> - <% cert_status = cert_statuses.get(enrollment.course_id) %> - <% can_unenroll = (not cert_status) or cert_status.get('can_unenroll') %> - <% credit_status = credit_statuses.get(enrollment.course_id) %> - <% show_email_settings = (enrollment.course_id in show_email_settings_for) %> - <% course_mode_info = all_course_modes.get(enrollment.course_id) %> - <% is_paid_course = (enrollment.course_id in enrolled_courses_either_paid) %> - <% is_course_blocked = (enrollment.course_id in block_courses) %> - <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> - <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> - <% related_programs = inverted_programs.get(unicode(enrollment.course_id)) %> - <% show_consent_link = (enrollment.course_id in consent_required_courses) %> - <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> + % for dashboard_index, enrollment in enumerate(course_entitlements + course_enrollments): + <% + # Check if the course run is an entitlement and if it has an associated session + entitlement = enrollment if isinstance(enrollment, CourseEntitlement) else None + entitlement_session = entitlement.enrollment_course_run if entitlement else None + + is_fulfilled_entitlement = True if entitlement and entitlement_session else False + is_unfulfilled_entitlement = True if entitlement and not entitlement_session else False + + entitlement_available_sessions = [] + if entitlement: + # Grab the available, enrollable sessions for a given entitlement and scrape them for relevant attributes + entitlement_available_sessions = [{ + 'session_id': course['key'], + 'enrollment_end': course['enrollment_end'], + 'pacing_type': course['pacing_type'], + 'session_start_advertised': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).advertised_start, + 'session_start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).start, + 'session_end': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).end, + } for course in course_entitlement_available_sessions[str(entitlement.uuid)]] + if is_fulfilled_entitlement: + # If the user has a fulfilled entitlement, pass through the entitlements CourseEnrollment object + enrollment = entitlement_session + else: + # If the user has an unfulfilled entitlement, pass through a bare CourseEnrollment object built off of the next available session + upcoming_sessions = course_entitlement_available_sessions[str(entitlement.uuid)] + next_session = upcoming_sessions[0] if upcoming_sessions else None + if not next_session: + continue + enrollment = CourseEnrollment(user=user, course_id=next_session['key'], mode=next_session['type']) + + session_id = enrollment.course_id + show_courseware_link = (session_id in show_courseware_links_for) + cert_status = cert_statuses.get(session_id) + can_unenroll = (not cert_status) or cert_status.get('can_unenroll') if not unfulfilled_entitlement else False + credit_status = credit_statuses.get(session_id) + show_email_settings = (session_id in show_email_settings_for) + course_mode_info = all_course_modes.get(session_id) + is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid) + is_course_blocked = (session_id in block_courses) + course_verification_status = verification_status_by_course.get(session_id, {}) + course_requirements = courses_requirements_not_met.get(session_id) + related_programs = inverted_programs.get(unicode(session_id)) + show_consent_link = (session_id in consent_required_courses) + course_overview = enrollment.course_overview + %> + <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> % endfor </ul> diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 21a7c54139c94842b8efe1f5f8ee82a7513bb0b5..c09ad888944708fa188b12a76c88ac2af92d23c1 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name" expression_filter="h"/> +<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name" expression_filter="h"/> <%! import urllib @@ -59,11 +59,12 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ lang="${course_overview.language}" % endif > -<div class="course${mode_class}" aria-labelledby="course-title-${enrollment.course_id}"> +<div class="course${mode_class}" aria-labelledby="course-title-${enrollment.course_id}" id="course-card-${course_card_index}"> <% course_target = reverse(course_home_url_name(course_overview.id), args=[unicode(course_overview.id)]) %> - <div class="details"> + <section class="details" aria-labelledby="details-heading-${course_overview.number}"> + <h2 class="hd hd-2 sr" id="details-heading-${course_overview.number}">${_('Course details')}</h2> <div class="wrapper-course-image" aria-hidden="true"> - % if show_courseware_link: + % if show_courseware_link and not is_unfulfilled_entitlement: % if not is_course_blocked: <a href="${course_target}" data-course-key="${enrollment.course_id}" class="cover" tabindex="-1"> <img src="${course_overview.image_urls['small']}" class="course-image" alt="${_('{course_number} {course_name} Home Page').format(course_number=course_overview.number, course_name=course_overview.display_name_with_default)}" /> @@ -90,7 +91,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ </div> <div class="wrapper-course-details"> <h3 class="course-title" id="course-title-${enrollment.course_id}"> - % if show_courseware_link: + % if show_courseware_link and not is_unfulfilled_entitlement: % if not is_course_blocked: <a data-course-key="${enrollment.course_id}" href="${course_target}">${course_overview.display_name_with_default}</a> % else: @@ -126,18 +127,27 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ endif %> - % if isinstance(course_date, basestring): - <span class="info-date-block" data-tooltip="Hi">${_(container_string).format(date=course_date)}</span> - % elif course_date is not None: - <% - course_date_string = course_date.strftime('%Y-%m-%dT%H:%M:%S%z') - %> - <span class="info-date-block localized-datetime" data-language="${user_language}" data-tooltip="Hi" data-timezone="${user_timezone}" data-datetime="${course_date_string}" data-format=${format} data-string="${container_string}"></span> + <span class="info-date-block-container"> + % if is_unfulfilled_entitlement: + <span class="info-date-block" aria-live="polite"> + <span class="icon fa fa-warning" aria-hidden="true"></span> + ${_('You must select a session to access the course.')} + </span> + % else: + % if isinstance(course_date, basestring): + <span class="info-date-block">${container_string.format(date=course_date)}</span> + % elif course_date is not None: + <span class="info-date-block localized-datetime" data-language="${user_language}" data-timezone="${user_timezone}" data-datetime="${course_date.strftime('%Y-%m-%dT%H:%M:%S%z')}" data-format=${format} data-string="${container_string}"></span> + % endif % endif + % if entitlement: + <button class="change-session btn-link ${'hidden' if is_unfulfilled_entitlement else ''}" aria-controls="change-session-${str(entitlement.uuid)}">${_('Change Session')}</button> + % endif + </span> </div> <div class="wrapper-course-actions"> <div class="course-actions"> - % if show_courseware_link: + % if show_courseware_link or is_unfulfilled_entitlement: % if course_overview.has_ended(): % if not is_course_blocked: <a href="${course_target}" class="enter-course archived" data-course-key="${enrollment.course_id}">${_('View Archived Course')}<span class="sr"> ${course_overview.display_name_with_default}</span></a> @@ -146,7 +156,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ % endif % else: % if not is_course_blocked: - <a href="${course_target}" class="enter-course" data-course-key="${enrollment.course_id}">${_('View Course')}<span class="sr"> ${course_overview.display_name_with_default}</span></a> + <a href="${course_target}" class="enter-course ${'hidden' if is_unfulfilled_entitlement else ''}" data-course-key="${enrollment.course_id}">${_('View Course')}<span class="sr"> ${course_overview.display_name_with_default}</span></a> % else: <a class="enter-course-blocked" data-course-key="${enrollment.course_id}">${_('View Course')}<span class="sr"> ${course_overview.display_name_with_default}</span></a> % endif @@ -205,68 +215,91 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ </a> % endif % endif - % endif - <div class="wrapper-action-more" data-course-key="${enrollment.course_id}"> - <button type="button" class="action action-more" id="actions-dropdown-link-${dashboard_index}" aria-haspopup="true" aria-expanded="false" aria-controls="actions-dropdown-${dashboard_index}" data-course-number="${course_overview.number}" data-course-name="${course_overview.display_name_with_default}" data-dashboard-index="${dashboard_index}"> - <span class="sr">${_('Course options for')}</span> - <span class="sr"> - ${course_overview.display_name_with_default} - </span> - <span class="fa fa-cog" aria-hidden="true"></span> - </button> - <div class="actions-dropdown" id="actions-dropdown-${dashboard_index}" tabindex="-1"> - <ul class="actions-dropdown-list" id="actions-dropdown-list-${dashboard_index}" aria-label="${_('Available Actions')}" role="menu"> - % if can_unenroll: - <li class="actions-item" id="actions-item-unenroll-${dashboard_index}" role="menuitem"> - <% course_refund_url = reverse('course_run_refund_status', args=[unicode(course_overview.id)]) %> + % if not entitlement: + <div class="wrapper-action-more" data-course-key="${enrollment.course_id}"> + <button type="button" class="action action-more" id="actions-dropdown-link-${dashboard_index}" aria-haspopup="true" aria-expanded="false" aria-controls="actions-dropdown-${dashboard_index}" data-course-number="${course_overview.number}" data-course-name="${course_overview.display_name_with_default}" data-dashboard-index="${dashboard_index}"> + <span class="sr">${_('Course options for')}</span> + <span class="sr"> + ${course_overview.display_name_with_default} + </span> + <span class="fa fa-cog" aria-hidden="true"></span> + </button> + <div class="actions-dropdown" id="actions-dropdown-${dashboard_index}" tabindex="-1"> + <ul class="actions-dropdown-list" id="actions-dropdown-list-${dashboard_index}" aria-label="${_('Available Actions')}" role="menu"> + % if can_unenroll: + <li class="actions-item" id="actions-item-unenroll-${dashboard_index}" role="menuitem"> + <% course_refund_url = reverse('course_run_refund_status', args=[unicode(course_overview.id)]) %> + % if not is_course_blocked: + <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" + data-course-id="${course_overview.id}" + data-course-number="${course_overview.number}" + data-course-name="${course_overview.display_name_with_default}" + data-dashboard-index="${dashboard_index}" + data-course-refund-url="${course_refund_url}" + data-course-is-paid-course="${is_paid_course}" + data-course-cert-name-long="${cert_name_long}" + data-course-enrollment-mode="${enrollment.mode}"> + ${_('Unenroll')} + </a> + % else: + <a class="action action-unenroll is-disabled" + data-course-id="${course_overview.id}" + data-course-number="${course_overview.number}" + data-course-name="${course_overview.display_name_with_default}" + data-dashboard-index="${dashboard_index}" + data-course-refund-url="${course_refund_url}" + data-course-is-paid-course="${is_paid_course}" + data-course-cert-name-long="${cert_name_long}" + data-course-enrollment-mode="${enrollment.mode}"> + ${_('Unenroll')} + </a> + % endif + </li> + % endif + <li class="actions-item" id="actions-item-email-settings-${dashboard_index}" role="menuitem"> + % if show_email_settings: % if not is_course_blocked: - <a href="#unenroll-modal" class="action action-unenroll" rel="leanModal" - data-course-id="${course_overview.id}" - data-course-number="${course_overview.number}" - data-course-name="${course_overview.display_name_with_default}" - data-dashboard-index="${dashboard_index}" - data-course-refund-url="${course_refund_url}" - data-course-is-paid-course="${is_paid_course}" - data-course-cert-name-long="${cert_name_long}" - data-course-enrollment-mode="${enrollment.mode}"> - ${_('Unenroll')} - </a> + <a href="#email-settings-modal" class="action action-email-settings" rel="leanModal" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a> % else: - <a class="action action-unenroll is-disabled" - data-course-id="${course_overview.id}" - data-course-number="${course_overview.number}" - data-course-name="${course_overview.display_name_with_default}" - data-dashboard-index="${dashboard_index}" - data-course-refund-url="${course_refund_url}" - data-course-is-paid-course="${is_paid_course}" - data-course-cert-name-long="${cert_name_long}" - data-course-enrollment-mode="${enrollment.mode}"> - ${_('Unenroll')} - </a> + <a class="action action-email-settings is-disabled" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a> + % endif % endif - </li> - % endif - <li class="actions-item" id="actions-item-email-settings-${dashboard_index}" role="menuitem"> - % if show_email_settings: - % if not is_course_blocked: - <a href="#email-settings-modal" class="action action-email-settings" rel="leanModal" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a> - % else: - <a class="action action-email-settings is-disabled" data-course-id="${course_overview.id}" data-course-number="${course_overview.number}" data-dashboard-index="${dashboard_index}" data-optout="${unicode(course_overview.id) in course_optouts}">${_('Email Settings')}</a> - % endif - % endif - </li> - </ul> - </div> - </div> + </li> + </ul> + </div> + </div> + % endif </div> </div> </div> - </div> + </section> <footer class="wrapper-messages-primary"> <div class="messages-list"> - % if related_programs: + + % if entitlement: + <div class="course-entitlement-selection-container ${'' if is_unfulfilled_entitlement else 'hidden'}"></div> + <%static:require_module module_name="js/learner_dashboard/course_entitlement_factory" class_name="EntitlementFactory"> + EntitlementFactory({ + el: '${ '#course-card-' + str(course_card_index) + ' .course-entitlement-selection-container' | n, js_escaped_string }', + triggerOpenBtn: '${ '#course-card-' + str(course_card_index) + ' .change-session' | n, js_escaped_string }', + courseCardMessages: '${ '#course-card-' + str(course_card_index) + ' .messages-list > .message' | n, js_escaped_string }', + courseTitleLink: '${ '#course-card-' + str(course_card_index) + ' .course-title a' | n, js_escaped_string }', + courseImageLink: '${ '#course-card-' + str(course_card_index) + ' .wrapper-course-image > a' | n, js_escaped_string }', + dateDisplayField: '${ '#course-card-' + str(course_card_index) + ' .info-date-block' | n, js_escaped_string }', + enterCourseBtn: '${ '#course-card-' + str(course_card_index) + ' .enter-course' | n, js_escaped_string }', + availableSessions: '${ entitlement_available_sessions | n, dump_js_escaped_json }', + entitlementUUID: '${ entitlement.course_uuid | n, js_escaped_string }', + currentSessionId: '${ entitlement_session.course_id if entitlement_session else '' | n, js_escaped_string }', + userId: '${ user.id | n, js_escaped_string }', + enrollUrl: '${ reverse('entitlements_api:v1:enrollments', args=[str(entitlement.uuid)]) | n, js_escaped_string }', + courseHomeUrl: '${ course_target | n, js_escaped_string }' + }); + </%static:require_module> + %endif + + % if related_programs and not entitlement: <div class="message message-related-programs is-shown"> <span class="related-programs-preface" tabindex="0">${_('Related Programs')}:</span> <ul> @@ -358,7 +391,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ </div> % endif - % if course_mode_info['show_upsell']: + % if course_mode_info and course_mode_info['show_upsell'] and not entitlement: <div class="message message-upsell has-actions is-shown"> <div class="wrapper-extended"> <p class="message-copy" align="justify"> @@ -410,7 +443,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ % endif </div> </footer> -</div> +</article> </div> </li> <script> diff --git a/lms/templates/learner_dashboard/course_entitlement.underscore b/lms/templates/learner_dashboard/course_entitlement.underscore new file mode 100644 index 0000000000000000000000000000000000000000..48a2730d409ec862095aff2faa70926cdba707a3 --- /dev/null +++ b/lms/templates/learner_dashboard/course_entitlement.underscore @@ -0,0 +1,33 @@ +<div id="change-session-<%- entitlementUUID %>" class="message is-shown"> + <div class="action-header"> + <% if (currentSessionId) { %> + <%- gettext('Change to a different session or leave the current session.')%> + <% } else { %> + <%- gettext('To access the course, select a session.')%> + <% } %> + </div> + <div class="action-controls"> + <select class="session-select" aria-label="<%- StringUtils.interpolate( gettext('Session Selection Dropdown for {courseName}'), { courseName: courseName }) %>"> + <% _.each(availableSessions, function(session) { %> + <option data-session_id="<%- session.session_id %>"> + <% if (session.session_id === currentSessionId) { %> + <%- StringUtils.interpolate( gettext('{sessionDates} - Currently Selected'), {sessionDates: session.session_dates}) %> + <% } else if (session.enrollment_end){ %> + <%- StringUtils.interpolate( gettext('{sessionDates} (Open until {enrollmentEnd})'), {sessionDates: session.session_dates, enrollmentEnd: session.enrollment_end}) %> + <% } else { %> + <%- session.session_dates %> + <% } %> + </option> + <% }) %> + <option disabled><%- gettext('More sessions coming soon') %></option> + <% if (currentSessionId){%> <option><%- gettext('Leave the current session and decide later')%></option><% } %> + </select> + <button class="enroll-btn-initial"> + <% if (currentSessionId) { %> + <%- gettext('Change Session') %> + <% } else { %> + <%- gettext('Select Session') %> + <% } %> + </button> + </div> +</div> diff --git a/lms/templates/learner_dashboard/verification_popover.underscore b/lms/templates/learner_dashboard/verification_popover.underscore new file mode 100644 index 0000000000000000000000000000000000000000..7191fe8e61887c933d17d2c6500b4a372314f1e3 --- /dev/null +++ b/lms/templates/learner_dashboard/verification_popover.underscore @@ -0,0 +1,16 @@ +<div class="verification-modal" role="dialog" aria-labelledby="enrollment-verification-title"> + <p id="enrollment-verification-title"> + <div class="popover-title"> + <%- confirmationMsgTitle %> + </div> + <%- confirmationMsgBody %> + </p> + <div class="action-items"> + <button type="button" class="popover-dismiss final-confirmation-btn"> + <%- gettext('Cancel') %> + </button> + <button type="button" class="enroll-btn final-confirmation-btn"> + <%- gettext('OK') %> + </button> + </div> + </div> \ No newline at end of file diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index 4f06845900ef7bf4ee53064a5764ec9d81986a1d..6ba2aa638105f73e9a5e920d2a8639b12af51f8c 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -233,7 +233,7 @@ def get_course_runs_for_course(course_uuid): resource_id=course_uuid, api=api, cache_key=cache_key if catalog_integration.is_cache_enabled else None, - long_term_cache=True + long_term_cache=True, ) return data.get('course_runs', []) else: diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index b9af125de1317605d243809a5db70cc728f14437..32eba8b28546950fb6da019c1f2ee0837a090bb4 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -9,9 +9,14 @@ import third_party_auth from third_party_auth import pipeline from django.core.urlresolvers import reverse import json +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML, Text -from openedx.core.djangoapps.theming import helpers as theming_helpers + +from entitlements.models import CourseEntitlement +from student.models import CourseEnrollment %> <% @@ -108,28 +113,58 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers <header class="wrapper-header-courses"> <h2 class="header-courses">${_("My Courses")}</h2> </header> + % if len(course_entitlements + course_enrollments) > 0: + <ul class="listing-courses"> + <% share_settings = getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}) %> + % for dashboard_index, enrollment in enumerate(course_entitlements + course_enrollments): + <% + # Check if the course run is an entitlement and if it has an associated session + entitlement = enrollment if isinstance(enrollment, CourseEntitlement) else None + entitlement_session = entitlement.enrollment_course_run if entitlement else None + is_fulfilled_entitlement = True if entitlement and entitlement_session else False + is_unfulfilled_entitlement = True if entitlement and not entitlement_session else False - % if len(course_enrollments) > 0: - <ul class="listing-courses"> - <% share_settings = getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}) %> - % for dashboard_index, enrollment in enumerate(course_enrollments): - <% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %> - <% cert_status = cert_statuses.get(enrollment.course_id) %> - <% can_unenroll = (not cert_status) or cert_status.get('can_unenroll') %> - <% credit_status = credit_statuses.get(enrollment.course_id) %> - <% show_email_settings = (enrollment.course_id in show_email_settings_for) %> - <% course_mode_info = all_course_modes.get(enrollment.course_id) %> - <% is_paid_course = (enrollment.course_id in enrolled_courses_either_paid) %> - <% is_course_blocked = (enrollment.course_id in block_courses) %> - <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> - <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> - <% related_programs = inverted_programs.get(unicode(enrollment.course_id)) %> - <% show_consent_link = (enrollment.course_id in consent_required_courses) %> - <%include file = 'dashboard/_dashboard_course_listing.html' args='course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> - % endfor + entitlement_available_sessions = [] + if entitlement: + # Grab the available, enrollable sessions for a given entitlement and scrape them for relevant attributes + entitlement_available_sessions = [{ + 'session_id': course['key'], + 'enrollment_end': course['enrollment_end'], + 'pacing_type': course['pacing_type'], + 'session_start_advertised': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).advertised_start, + 'session_start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).start, + 'session_end': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).end, + } for course in course_entitlement_available_sessions[str(entitlement.uuid)]] + if is_fulfilled_entitlement: + # If the user has a fulfilled entitlement, pass through the entitlements CourseEnrollment object + enrollment = entitlement_session + else: + # If the user has an unfulfilled entitlement, pass through a bare CourseEnrollment object built off of the next available session + upcoming_sessions = course_entitlement_available_sessions[str(entitlement.uuid)] + next_session = upcoming_sessions[0] if upcoming_sessions else None + if not next_session: + continue + enrollment = CourseEnrollment(user=user, course_id=next_session['key'], mode=next_session['type']) - </ul> + session_id = enrollment.course_id + show_courseware_link = (session_id in show_courseware_links_for) + cert_status = cert_statuses.get(session_id) + can_unenroll = (not cert_status) or cert_status.get('can_unenroll') if not unfulfilled_entitlement else False + credit_status = credit_statuses.get(session_id) + show_email_settings = (session_id in show_email_settings_for) + course_mode_info = all_course_modes.get(session_id) + is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid) + is_course_blocked = (session_id in block_courses) + course_verification_status = verification_status_by_course.get(session_id, {}) + course_requirements = courses_requirements_not_met.get(session_id) + related_programs = inverted_programs.get(unicode(session_id)) + show_consent_link = (session_id in consent_required_courses) + course_overview = enrollment.course_overview + %> + <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> + % endfor + </ul> % else: <section class="empty-dashboard-message"> <p>${_("You are not enrolled in any courses yet.")}</p>