Add notification to program detail view
ECOM-7385
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
'js/learner_dashboard/models/course_enroll_model',
|
||||
'js/learner_dashboard/views/upgrade_message_view_2017',
|
||||
'js/learner_dashboard/views/certificate_status_view_2017',
|
||||
'js/learner_dashboard/views/expired_notification_view',
|
||||
'js/learner_dashboard/views/course_enroll_view_2017',
|
||||
'text!../../../templates/learner_dashboard/course_card_2017.underscore'
|
||||
],
|
||||
@@ -21,6 +22,7 @@
|
||||
EnrollModel,
|
||||
UpgradeMessageView,
|
||||
CertificateStatusView,
|
||||
ExpiredNotificationView,
|
||||
CourseEnrollView,
|
||||
pageTpl
|
||||
) {
|
||||
@@ -50,7 +52,8 @@
|
||||
|
||||
postRender: function() {
|
||||
var $upgradeMessage = this.$('.upgrade-message'),
|
||||
$certStatus = this.$('.certificate-status');
|
||||
$certStatus = this.$('.certificate-status'),
|
||||
$expiredNotification = this.$('.expired-notification');
|
||||
|
||||
this.enrollView = new CourseEnrollView({
|
||||
$parentEl: this.$('.course-actions'),
|
||||
@@ -78,6 +81,13 @@
|
||||
$upgradeMessage.remove();
|
||||
$certStatus.remove();
|
||||
}
|
||||
|
||||
if (this.model.get('expired')) {
|
||||
this.expiredNotification = new ExpiredNotificationView({
|
||||
$el: $expiredNotification,
|
||||
model: this.model
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
(function(define) {
|
||||
'use strict';
|
||||
define(['backbone',
|
||||
'jquery',
|
||||
'underscore',
|
||||
'gettext',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'text!../../../templates/learner_dashboard/expired_notification.underscore'
|
||||
],
|
||||
function(
|
||||
Backbone,
|
||||
$,
|
||||
_,
|
||||
gettext,
|
||||
HtmlUtils,
|
||||
expiredNotificationTpl
|
||||
) {
|
||||
return Backbone.View.extend({
|
||||
expiredNotificationTpl: HtmlUtils.template(expiredNotificationTpl),
|
||||
|
||||
initialize: function(options) {
|
||||
this.$el = options.$el;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var data = this.model.toJSON();
|
||||
HtmlUtils.setHtml(this.$el, this.expiredNotificationTpl(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -5,9 +5,9 @@
|
||||
'jquery',
|
||||
'edx-ui-toolkit/js/utils/html-utils',
|
||||
'text!../../../templates/learner_dashboard/program_header_view_2017.underscore',
|
||||
'text!/static/images/programs/micromasters-program-details.svg',
|
||||
'text!/static/images/programs/xseries-program-details.svg',
|
||||
'text!/static/images/programs/professional-certificate-program-details.svg'
|
||||
'text!../../../images/programs/micromasters-program-details.svg',
|
||||
'text!../../../images/programs/xseries-program-details.svg',
|
||||
'text!../../../images/programs/professional-certificate-program-details.svg'
|
||||
],
|
||||
function(Backbone, $, HtmlUtils, pageTpl, MicroMastersLogo,
|
||||
XSeriesLogo, ProfessionalCertificateLogo) {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
'js/groups/views/cohorts_dashboard_factory',
|
||||
'js/header_factory',
|
||||
'js/learner_dashboard/program_details_factory',
|
||||
'js/learner_dashboard/program_details_factory_2017',
|
||||
'js/learner_dashboard/program_list_factory',
|
||||
'js/search/course/course_search_factory',
|
||||
'js/search/dashboard/dashboard_search_factory',
|
||||
|
||||
@@ -357,7 +357,7 @@
|
||||
margin-bottom: 10px;
|
||||
|
||||
@media(min-width: $bp-screen-md) {
|
||||
height: 100px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -366,7 +366,7 @@
|
||||
margin-right: 40px;
|
||||
margin-left: 15px;
|
||||
|
||||
@media(min-width: $bp-screen-md) {
|
||||
@media(min-width: $bp-screen-sm) {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
@@ -496,5 +496,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
.expired-notification {
|
||||
display: inline-block;
|
||||
padding-top: 5px;
|
||||
width: 300px;
|
||||
|
||||
@media(min-width: $bp-screen-sm) {
|
||||
padding-top: 10px;
|
||||
width: 500px;
|
||||
}
|
||||
@media(min-width: $bp-screen-md) {
|
||||
width: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.expired-icon {
|
||||
float: left;
|
||||
color: palette(primary, dark);
|
||||
}
|
||||
|
||||
.expired-text {
|
||||
overflow: hidden;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
</div>
|
||||
<div class="section action-msg-view"></div>
|
||||
<div class="section upgrade-message"></div>
|
||||
|
||||
<div class="section expired-notification"></div>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<div class="expired-icon"><span class="fa fa-info-circle fa-lg" aria-hidden="true"></span></div>
|
||||
<div class="expired-text">You enrolled in this course but did not earn the certificate required to complete this program.</div>
|
||||
@@ -40,6 +40,7 @@ CERTIFICATES_API_MODULE = 'lms.djangoapps.certificates.api'
|
||||
ECOMMERCE_URL_ROOT = 'https://example-ecommerce.com'
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@attr(shard=2)
|
||||
@skip_unless_lms
|
||||
@mock.patch(UTILS_MODULE + '.get_programs')
|
||||
@@ -53,7 +54,7 @@ class TestProgramProgressMeter(TestCase):
|
||||
def _create_enrollments(self, *course_run_ids):
|
||||
"""Variadic helper used to create course run enrollments."""
|
||||
for course_run_id in course_run_ids:
|
||||
CourseEnrollmentFactory(user=self.user, course_id=course_run_id)
|
||||
CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode='verified')
|
||||
|
||||
def _assert_progress(self, meter, *progresses):
|
||||
"""Variadic helper used to verify progress calculations."""
|
||||
@@ -150,6 +151,46 @@ class TestProgramProgressMeter(TestCase):
|
||||
|
||||
self.assertEqual(meter.progress(count_only=False), expected)
|
||||
|
||||
@ddt.data(1, -1)
|
||||
def test_in_progress_course_upgrade_deadline_check(self, modifier, mock_get_programs):
|
||||
"""
|
||||
Verify that if the user's enrollment is not of the same type as the course run,
|
||||
the course will only count as in progress if there is another available seat with
|
||||
the right type, where the upgrade deadline has not expired.
|
||||
"""
|
||||
course_run_key = generate_course_run_key()
|
||||
now = datetime.datetime.now(utc)
|
||||
date_modifier = modifier * datetime.timedelta(days=1)
|
||||
seat_with_upgrade_deadline = SeatFactory(type='test', upgrade_deadline=str(now + date_modifier))
|
||||
enrolled_seat = SeatFactory(type='verified')
|
||||
seats = [seat_with_upgrade_deadline, enrolled_seat]
|
||||
|
||||
data = [
|
||||
ProgramFactory(
|
||||
courses=[
|
||||
CourseFactory(course_runs=[
|
||||
CourseRunFactory(key=course_run_key, type='test', seats=seats),
|
||||
]),
|
||||
]
|
||||
)
|
||||
]
|
||||
mock_get_programs.return_value = data
|
||||
|
||||
self._create_enrollments(course_run_key)
|
||||
|
||||
meter = ProgramProgressMeter(self.user)
|
||||
|
||||
program = data[0]
|
||||
expected = [
|
||||
ProgressFactory(
|
||||
uuid=program['uuid'],
|
||||
completed=0,
|
||||
in_progress=1 if modifier == 1 else 0,
|
||||
not_started=1 if modifier == -1 else 0
|
||||
)
|
||||
]
|
||||
self.assertEqual(meter.progress(count_only=True), expected)
|
||||
|
||||
def test_mutiple_program_engagement(self, mock_get_programs):
|
||||
"""
|
||||
Verify that correct programs are returned in the correct order when the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Helper functions for working with Programs."""
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
import datetime
|
||||
from urlparse import urljoin
|
||||
|
||||
@@ -69,8 +70,14 @@ class ProgramProgressMeter(object):
|
||||
self.enrollments = enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
|
||||
self.enrollments.sort(key=lambda e: e.created, reverse=True)
|
||||
|
||||
# enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
|
||||
self.course_run_ids = [unicode(e.course_id) for e in self.enrollments]
|
||||
self.enrolled_run_modes = {}
|
||||
self.course_run_ids = []
|
||||
for enrollment in self.enrollments:
|
||||
# enrollment.course_id is really a CourseKey (╯ಠ_ಠ)╯︵ ┻━┻
|
||||
enrollment_id = unicode(enrollment.course_id)
|
||||
self.enrolled_run_modes[enrollment_id] = enrollment.mode
|
||||
# We can't use dict.keys() for this because the course run ids need to be ordered
|
||||
self.course_run_ids.append(enrollment_id)
|
||||
|
||||
if uuid:
|
||||
self.programs = [get_programs(uuid=uuid)]
|
||||
@@ -127,6 +134,44 @@ class ProgramProgressMeter(object):
|
||||
|
||||
return programs
|
||||
|
||||
def _is_course_in_progress(self, now, course):
|
||||
"""Check if course qualifies as in progress as part of the program.
|
||||
|
||||
A course is considered to be in progress if a user is enrolled in a run
|
||||
of the correct mode or a run of the correct mode is still available for enrollment.
|
||||
|
||||
Arguments:
|
||||
now (datetime): datetime for now
|
||||
course (dict): Containing nested course runs.
|
||||
|
||||
Returns:
|
||||
bool, indicating whether the course is in progress.
|
||||
"""
|
||||
# Part 1: Check if any of the seats you are enrolled in qualify this course as in progress
|
||||
enrolled_runs = [run for run in course['course_runs'] if run['key'] in self.course_run_ids]
|
||||
# Check if the user is enrolled in the required mode for the run
|
||||
runs_with_required_mode = [
|
||||
run for run in enrolled_runs
|
||||
if run['type'] == self.enrolled_run_modes[run['key']]
|
||||
]
|
||||
if runs_with_required_mode:
|
||||
# Check if the runs you are enrolled in with the right mode are not failed
|
||||
not_failed_runs = [run for run in runs_with_required_mode if run not in self.failed_course_runs]
|
||||
if not_failed_runs:
|
||||
return True
|
||||
# Part 2: Check if any of the seats you are not enrolled in
|
||||
# in the runs you are enrolled in qualify this course as in progress
|
||||
upgrade_deadlines = []
|
||||
for run in enrolled_runs:
|
||||
for seat in run['seats']:
|
||||
if seat['type'] == run['type'] and run['type'] != self.enrolled_run_modes[run['key']]:
|
||||
upgrade_deadlines.append(seat['upgrade_deadline'])
|
||||
|
||||
course_still_upgradeable = any(
|
||||
(deadline is not None) and (parse(deadline) > now) for deadline in upgrade_deadlines
|
||||
)
|
||||
return course_still_upgradeable
|
||||
|
||||
def progress(self, programs=None, count_only=True):
|
||||
"""Gauge a user's progress towards program completion.
|
||||
|
||||
@@ -142,21 +187,29 @@ class ProgramProgressMeter(object):
|
||||
list of dict, each containing information about a user's progress
|
||||
towards completing a program.
|
||||
"""
|
||||
now = datetime.datetime.now(utc)
|
||||
|
||||
progress = []
|
||||
programs = programs or self.engaged_programs
|
||||
for program in programs:
|
||||
program_copy = deepcopy(program)
|
||||
completed, in_progress, not_started = [], [], []
|
||||
|
||||
for course in program['courses']:
|
||||
for course in program_copy['courses']:
|
||||
if self._is_course_complete(course):
|
||||
completed.append(course)
|
||||
elif self._is_course_in_progress(course):
|
||||
in_progress.append(course)
|
||||
elif self._is_course_enrolled(course):
|
||||
course_in_progress = self._is_course_in_progress(now, course)
|
||||
if course_in_progress:
|
||||
in_progress.append(course)
|
||||
else:
|
||||
course['expired'] = not course_in_progress
|
||||
not_started.append(course)
|
||||
else:
|
||||
not_started.append(course)
|
||||
|
||||
progress.append({
|
||||
'uuid': program['uuid'],
|
||||
'uuid': program_copy['uuid'],
|
||||
'completed': len(completed) if count_only else completed,
|
||||
'in_progress': len(in_progress) if count_only else in_progress,
|
||||
'not_started': len(not_started) if count_only else not_started,
|
||||
@@ -226,17 +279,43 @@ class ProgramProgressMeter(object):
|
||||
Returns:
|
||||
list of dicts, each representing a course run certificate
|
||||
"""
|
||||
return self.course_runs_with_state['completed']
|
||||
|
||||
@cached_property
|
||||
def failed_course_runs(self):
|
||||
"""
|
||||
Determine which course runs have been failed by the user.
|
||||
|
||||
Returns:
|
||||
list of dicts, each a course run ID
|
||||
"""
|
||||
return [run['course_run_id'] for run in self.course_runs_with_state['failed']]
|
||||
|
||||
@cached_property
|
||||
def course_runs_with_state(self):
|
||||
"""
|
||||
Determine which course runs have been completed and failed by the user.
|
||||
|
||||
Returns:
|
||||
dict with a list of completed and failed runs
|
||||
"""
|
||||
course_run_certificates = certificate_api.get_certificates_for_user(self.user.username)
|
||||
return [
|
||||
{'course_run_id': unicode(certificate['course_key']), 'type': certificate['type']}
|
||||
for certificate in course_run_certificates
|
||||
if certificate_api.is_passing_status(certificate['status'])
|
||||
]
|
||||
completed_runs, failed_runs = [], []
|
||||
for certificate in course_run_certificates:
|
||||
course_data = {
|
||||
'course_run_id': unicode(certificate['course_key']),
|
||||
'type': certificate['type']
|
||||
}
|
||||
if certificate_api.is_passing_status(certificate['status']):
|
||||
completed_runs.append(course_data)
|
||||
else:
|
||||
failed_runs.append(course_data)
|
||||
return {'completed': completed_runs, 'failed': failed_runs}
|
||||
|
||||
def _is_course_in_progress(self, course):
|
||||
"""Check if a user is in the process of completing a course.
|
||||
def _is_course_enrolled(self, course):
|
||||
"""Check if a user is enrolled in a course.
|
||||
|
||||
A user is considered to be in the process of completing a course if
|
||||
A user is considered to be enrolled in a course if
|
||||
they're enrolled in any of the nested course runs.
|
||||
|
||||
Arguments:
|
||||
|
||||
Reference in New Issue
Block a user