From 7330ff99b64da51f4aff36f6c3068120b87e37e2 Mon Sep 17 00:00:00 2001 From: Matthew Piatetsky Date: Fri, 7 Apr 2017 10:11:16 -0400 Subject: [PATCH] Add notification to program detail view ECOM-7385 --- .../views/course_card_view_2017.js | 12 +- .../views/expired_notification_view.js | 33 ++++++ .../views/program_header_view_2017.js | 6 +- lms/static/lms/js/build.js | 1 + lms/static/sass/views/_program-details.scss | 28 ++++- .../course_card_2017.underscore | 2 +- .../expired_notification.underscore | 2 + .../djangoapps/programs/tests/test_utils.py | 43 ++++++- openedx/core/djangoapps/programs/utils.py | 107 +++++++++++++++--- 9 files changed, 212 insertions(+), 22 deletions(-) create mode 100644 lms/static/js/learner_dashboard/views/expired_notification_view.js create mode 100644 lms/templates/learner_dashboard/expired_notification.underscore 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: