diff --git a/lms/static/js/learner_dashboard/views/course_card_view_2017.js b/lms/static/js/learner_dashboard/views/course_card_view_2017.js
index 0d10dec0e8..3f330e61e1 100644
--- a/lms/static/js/learner_dashboard/views/course_card_view_2017.js
+++ b/lms/static/js/learner_dashboard/views/course_card_view_2017.js
@@ -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
+ });
+ }
}
});
}
diff --git a/lms/static/js/learner_dashboard/views/expired_notification_view.js b/lms/static/js/learner_dashboard/views/expired_notification_view.js
new file mode 100644
index 0000000000..4b6fb2aaea
--- /dev/null
+++ b/lms/static/js/learner_dashboard/views/expired_notification_view.js
@@ -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);
diff --git a/lms/static/js/learner_dashboard/views/program_header_view_2017.js b/lms/static/js/learner_dashboard/views/program_header_view_2017.js
index 76beef11e0..78606ad54f 100644
--- a/lms/static/js/learner_dashboard/views/program_header_view_2017.js
+++ b/lms/static/js/learner_dashboard/views/program_header_view_2017.js
@@ -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) {
diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js
index 19e49f319a..c8e5a5d3aa 100644
--- a/lms/static/lms/js/build.js
+++ b/lms/static/lms/js/build.js
@@ -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',
diff --git a/lms/static/sass/views/_program-details.scss b/lms/static/sass/views/_program-details.scss
index ae8e3e23c1..02dbf1039b 100644
--- a/lms/static/sass/views/_program-details.scss
+++ b/lms/static/sass/views/_program-details.scss
@@ -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;
+ }
+
}
}
diff --git a/lms/templates/learner_dashboard/course_card_2017.underscore b/lms/templates/learner_dashboard/course_card_2017.underscore
index ec73da8d4c..d64c1511e8 100644
--- a/lms/templates/learner_dashboard/course_card_2017.underscore
+++ b/lms/templates/learner_dashboard/course_card_2017.underscore
@@ -25,4 +25,4 @@
-
+
diff --git a/lms/templates/learner_dashboard/expired_notification.underscore b/lms/templates/learner_dashboard/expired_notification.underscore
new file mode 100644
index 0000000000..d78ce681d6
--- /dev/null
+++ b/lms/templates/learner_dashboard/expired_notification.underscore
@@ -0,0 +1,2 @@
+
+You enrolled in this course but did not earn the certificate required to complete this program.
diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py
index d7087f35a5..9bca686466 100644
--- a/openedx/core/djangoapps/programs/tests/test_utils.py
+++ b/openedx/core/djangoapps/programs/tests/test_utils.py
@@ -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
diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py
index 6bd919a461..9de403e602 100644
--- a/openedx/core/djangoapps/programs/utils.py
+++ b/openedx/core/djangoapps/programs/utils.py
@@ -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: