Commit be5c4fad by Matthew Piatetsky Committed by GitHub

Merge pull request #14833 from edx/remainingnotification

ECOM-7385 Add notification when a course is moved from in progress to remaining
parents eb1ec84e 7330ff99
......@@ -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
});
}
}
});
}
......
(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>
<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
......
# -*- 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:
......
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