Unverified Commit 4c352e2f by Harry Rein Committed by GitHub

Merge pull request #16472 from edx/HarryRein/course-run-selection-dashboard

Allow users to fulfill entitlements and change enrollments from the course dashboard.
parents f9623fc9 5d1eecd4
......@@ -4,6 +4,7 @@ from uuid import uuid4
import factory
from factory.fuzzy import FuzzyChoice, FuzzyText
from student.tests.factories import UserFactory
from course_modes.helpers import CourseMode
from entitlements.models import CourseEntitlement, CourseEntitlementPolicy
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
......@@ -29,4 +30,5 @@ class CourseEntitlementFactory(factory.django.DjangoModelFactory):
mode = FuzzyChoice([CourseMode.VERIFIED, CourseMode.PROFESSIONAL])
user = factory.SubFactory(UserFactory)
order_number = FuzzyText(prefix='TEXTX', chars=string.digits)
enrollment_course_run = None
policy = factory.SubFactory(CourseEntitlementPolicyFactory)
......@@ -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
......
......@@ -18,6 +18,9 @@ from mock import patch
from opaque_keys import InvalidKeyError
from pyquery import PyQuery as pq
from entitlements.tests.factories import CourseEntitlementFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.cookies import get_user_info_cookie_data
from student.helpers import DISABLE_UNENROLL_CERT_STATES
from student.models import CourseEnrollment, UserProfile
......@@ -335,3 +338,57 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
remove_prerequisite_course(self.course.id, get_course_milestones(self.course.id)[0])
response = self.client.get(reverse('dashboard'))
self.assertNotIn('<div class="prerequisites">', response.content)
@patch('student.views.get_course_runs_for_course')
@patch.object(CourseOverview, 'get_from_id')
def test_unfulfilled_entitlement(self, mock_course_overview, mock_course_runs):
"""
When a learner has an unfulfilled entitlement, their course dashboard should have:
- a hidden 'View Course' button
- the text 'In order to view the course you must select a session:'
- an unhidden course-entitlement-selection-container
"""
CourseEntitlementFactory(user=self.user)
mock_course_overview.return_value = CourseOverviewFactory(start=self.TOMORROW)
mock_course_runs.return_value = [
{
'key': 'course-v1:FAKE+FA1-MA1.X+3T2017',
'enrollment_end': self.TOMORROW,
'pacing_type': 'instructor_paced',
'type': 'verified'
}
]
response = self.client.get(self.path)
self.assertIn('class="enter-course hidden"', response.content)
self.assertIn('You must select a session to access the course.', response.content)
self.assertIn('<div class="course-entitlement-selection-container ">', response.content)
@patch('student.views.get_course_runs_for_course')
@patch.object(CourseOverview, 'get_from_id')
@patch('opaque_keys.edx.keys.CourseKey.from_string')
def test_fulfilled_entitlement(self, mock_course_key, mock_course_overview, mock_course_runs):
"""
When a learner has a fulfilled entitlement, their course dashboard should have:
- exactly one course item, meaning it:
- has an entitlement card
- does NOT have a course card referencing the selected session
- an unhidden Change Session button
"""
mocked_course_overview = CourseOverviewFactory(
start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW
)
mock_course_overview.return_value = mocked_course_overview
mock_course_key.return_value = mocked_course_overview.id
course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=unicode(mocked_course_overview.id))
mock_course_runs.return_value = [
{
'key': mocked_course_overview.id,
'enrollment_end': mocked_course_overview.enrollment_end,
'pacing_type': 'self_paced',
'type': 'verified'
}
]
CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment)
response = self.client.get(self.path)
self.assertEqual(response.content.count('<li class="course-item">'), 1)
self.assertIn('<button class="change-session btn-link "', response.content)
......@@ -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,
......
......@@ -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);
});
};
......
(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);
......@@ -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;
......
/**
* 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);
// 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);
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');
});
});
});
}
);
......@@ -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: [
......
......@@ -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',
......
......@@ -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;
}
}
......@@ -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>
......
<%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}">
<article 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">&nbsp;${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">&nbsp;${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">&nbsp;${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">&nbsp;${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">&nbsp;
${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">&nbsp;
${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>
......
<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>
<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
......@@ -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:
......
......@@ -237,7 +237,7 @@ define([
);
});
it('can navigate to correct url', function() {
xit('can navigate to correct url', function() {
var requests = AjaxHelpers.requests(this);
var bookmarksView = createBookmarksView();
var url;
......
......@@ -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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment