diff --git a/lms/djangoapps/learner_dashboard/README.rst b/lms/djangoapps/learner_dashboard/README.rst new file mode 100644 index 0000000000..7fd834058c --- /dev/null +++ b/lms/djangoapps/learner_dashboard/README.rst @@ -0,0 +1,23 @@ +Learner Dashboard +================= + +This Django app hosts dashboard pages used by edX learners. The intent is for this Django app to include the following three important dashboard tabs: + - Courses + - Programs + - Profile + +Courses +--------------- +The learner-facing dashboard listing active and archived enrollments. The current implementation of the dashboard resides in ``common/djangoapps/student/``. The goal is to replace the existing dashboard with a Backbone app served by this Django app. + +Programs +--------------- +A page listing programs in which the learner is engaged. The page also shows learners' progress towards completing the programs. Programs are structured collections of course runs which culminate into a certificate. + +Implementation +^^^^^^^^^^^^^^^^^^^^^ +The ``views`` module contains the Django views used to serve the Program listing page. The corresponding Backbone app is in the ``edx-platform/static/js/learner_dashboard``. + +Profile +--------------- +A page allowing learners to see what they have accomplished and view credits or certificates they have earned on the edX platform. diff --git a/lms/djangoapps/learner_dashboard/__init__.py b/lms/djangoapps/learner_dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_dashboard/tests/__init__.py b/lms/djangoapps/learner_dashboard/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py new file mode 100644 index 0000000000..5f22787557 --- /dev/null +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -0,0 +1,141 @@ +""" +Tests for viewing the programs enrolled by a learner. +""" +import datetime +import httpretty +import unittest +from urlparse import urljoin + +from django.conf import settings +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect +from django.test import override_settings +from oauth2_provider.tests.factories import ClientFactory +from opaque_keys.edx import locator +from provider.constants import CONFIDENTIAL + +from openedx.core.djangoapps.programs.tests.mixins import ( + ProgramsApiConfigMixin, + ProgramsDataMixin) +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from student.models import CourseEnrollment +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@override_settings(MKTG_URLS={'ROOT': 'http://edx.org'}) +class TestProgramListing( + ModuleStoreTestCase, + ProgramsApiConfigMixin, + ProgramsDataMixin): + + """ + Unit tests for getting the list of programs enrolled by a logged in user + """ + PASSWORD = 'test' + + def setUp(self): + """ + Add a student + """ + super(TestProgramListing, self).setUp() + ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) + self.student = UserFactory() + self.create_programs_config(xseries_ad_enabled=True) + + def _create_course_and_enroll(self, student, org, course, run): + """ + Creates a course and associated enrollment. + """ + course_location = locator.CourseLocator(org, course, run) + course = CourseFactory.create( + org=course_location.org, + number=course_location.course, + run=course_location.run + ) + enrollment = CourseEnrollment.enroll(student, course.id) + enrollment.created = datetime.datetime(2000, 12, 31, 0, 0, 0, 0) + enrollment.save() + + def _get_program_url(self, marketing_slug): + """ + Helper function to get the program card url + """ + return urljoin( + settings.MKTG_URLS.get('ROOT'), + 'xseries' + '/{}' + ).format(marketing_slug) + + def _setup_and_get_program(self): + """ + The core function to setup the mock program api, + then call the django test client to get the actual program listing page + make sure the request suceeds and make sure x_series_url is on the page + """ + self.mock_programs_api() + self.client.login(username=self.student.username, password=self.PASSWORD) + response = self.client.get(reverse("program_listing_view")) + self.assertEqual(response.status_code, 200) + x_series_url = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries') + self.assertIn(x_series_url, response.content) + return response + + def _get_program_checklist(self, program_id): + """ + The convenience function to get all the program related page element we would like to check against + """ + return [ + self.PROGRAM_NAMES[program_id], + self._get_program_url(self.PROGRAMS_API_RESPONSE['results'][program_id]['marketing_slug']), + self.PROGRAMS_API_RESPONSE['results'][program_id]['organizations'][0]['display_name'], + ] + + @httpretty.activate + def test_get_program_with_no_enrollment(self): + response = self._setup_and_get_program() + for program_element in self._get_program_checklist(0): + self.assertNotIn(program_element, response.content) + for program_element in self._get_program_checklist(1): + self.assertNotIn(program_element, response.content) + + @httpretty.activate + def test_get_one_program(self): + self._create_course_and_enroll(self.student, *self.COURSE_KEYS[0].split('/')) + response = self._setup_and_get_program() + for program_element in self._get_program_checklist(0): + self.assertIn(program_element, response.content) + for program_element in self._get_program_checklist(1): + self.assertNotIn(program_element, response.content) + + @httpretty.activate + def test_get_both_program(self): + self._create_course_and_enroll(self.student, *self.COURSE_KEYS[0].split('/')) + self._create_course_and_enroll(self.student, *self.COURSE_KEYS[5].split('/')) + response = self._setup_and_get_program() + for program_element in self._get_program_checklist(0): + self.assertIn(program_element, response.content) + for program_element in self._get_program_checklist(1): + self.assertIn(program_element, response.content) + + def test_get_programs_dashboard_not_enabled(self): + self.create_programs_config(enable_student_dashboard=False) + self.client.login(username=self.student.username, password=self.PASSWORD) + response = self.client.get(reverse("program_listing_view")) + self.assertEqual(response.status_code, 404) + + def test_xseries_advertise_disabled(self): + self.create_programs_config(xseries_ad_enabled=False) + self.client.login(username=self.student.username, password=self.PASSWORD) + response = self.client.get(reverse("program_listing_view")) + self.assertEqual(response.status_code, 200) + x_series_url = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries') + self.assertNotIn(x_series_url, response.content) + + def test_get_programs_not_logged_in(self): + self.create_programs_config() + response = self.client.get(reverse("program_listing_view")) + self.assertEqual(response.status_code, 302) + self.assertIsInstance(response, HttpResponseRedirect) + self.assertIn('login', response.url) # pylint: disable=no-member diff --git a/lms/djangoapps/learner_dashboard/urls.py b/lms/djangoapps/learner_dashboard/urls.py new file mode 100644 index 0000000000..548724c14a --- /dev/null +++ b/lms/djangoapps/learner_dashboard/urls.py @@ -0,0 +1,10 @@ +""" +Learner's Dashboard urls +""" + +from django.conf.urls import url +from . import views + +urlpatterns = [ + url(r'^programs/$', views.view_programs, name='program_listing_view'), +] diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py new file mode 100644 index 0000000000..85026a445c --- /dev/null +++ b/lms/djangoapps/learner_dashboard/views.py @@ -0,0 +1,36 @@ +"""New learner dashboard views.""" +from urlparse import urljoin + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.views.decorators.http import require_GET +from django.http import Http404 + +from edxmako.shortcuts import render_to_response +from openedx.core.djangoapps.programs.utils import get_engaged_programs +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from student.views import get_course_enrollments + + +@login_required +@require_GET +def view_programs(request): + """View programs in which the user is engaged.""" + if not ProgramsApiConfig.current().is_student_dashboard_enabled: + raise Http404 + + enrollments = list(get_course_enrollments(request.user, None, [])) + programs = get_engaged_programs(request.user, enrollments) + + # TODO: Pull 'xseries' string from configuration model. + marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').strip('/') + for program in programs: + program['marketing_url'] = '{root}/{slug}'.format( + root=marketing_root, + slug=program['marketing_slug'] + ) + + return render_to_response('learner_dashboard/programs.html', { + 'programs': programs, + 'xseries_url': marketing_root if ProgramsApiConfig.current().show_xseries_ad else None + }) diff --git a/lms/envs/common.py b/lms/envs/common.py index 288f78411c..57a7b02a2a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2019,6 +2019,9 @@ INSTALLED_APPS = ( # Verified Track Content Cohorting 'verified_track_content', + + # Learner's dashboard + 'learner_dashboard', ) # Migrations which are not in the standard module "migrations" diff --git a/lms/static/js/learner_dashboard/collections/program_collection.js b/lms/static/js/learner_dashboard/collections/program_collection.js new file mode 100644 index 0000000000..47ca233e44 --- /dev/null +++ b/lms/static/js/learner_dashboard/collections/program_collection.js @@ -0,0 +1,12 @@ +(function (define) { + 'use strict'; + define([ + 'backbone', + 'js/learner_dashboard/models/program_model' + ], + function (Backbone, Program) { + return Backbone.Collection.extend({ + model: Program + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/models/program_model.js b/lms/static/js/learner_dashboard/models/program_model.js new file mode 100644 index 0000000000..7a5ece3cf3 --- /dev/null +++ b/lms/static/js/learner_dashboard/models/program_model.js @@ -0,0 +1,24 @@ +/** + * Model for Course Programs. + */ +(function (define) { + 'use strict'; + define([ + 'backbone' + ], + function (Backbone) { + return Backbone.Model.extend({ + initialize: function(data) { + if (data){ + this.set({ + name: data.name, + category: data.category, + subtitle: data.subtitle, + organizations: data.organizations, + marketingUrl: data.marketing_url + }); + } + } + }); + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/program_list_factory.js b/lms/static/js/learner_dashboard/program_list_factory.js new file mode 100644 index 0000000000..8384ed9a94 --- /dev/null +++ b/lms/static/js/learner_dashboard/program_list_factory.js @@ -0,0 +1,24 @@ +;(function (define) { + 'use strict'; + + define([ + 'js/learner_dashboard/views/collection_list_view', + 'js/learner_dashboard/views/sidebar_view', + 'js/learner_dashboard/views/program_card_view', + 'js/learner_dashboard/collections/program_collection' + ], + function (CollectionListView, SidebarView, ProgramCardView, ProgramCollection) { + return function (options) { + new CollectionListView({ + el: '.program-cards-container', + childView: ProgramCardView, + collection: new ProgramCollection(options.programsData) + }).render(); + + new SidebarView({ + el: '.sidebar', + context: options + }).render(); + }; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/views/collection_list_view.js b/lms/static/js/learner_dashboard/views/collection_list_view.js new file mode 100644 index 0000000000..ab9fd18a5c --- /dev/null +++ b/lms/static/js/learner_dashboard/views/collection_list_view.js @@ -0,0 +1,24 @@ +;(function (define) { + 'use strict'; + + define(['backbone'], + function( + Backbone + ) { + return Backbone.View.extend({ + initialize: function(data) { + this.childView = data.childView; + }, + + render: function() { + var childList = []; + this.collection.each(function(program){ + var child = new this.childView({model:program}); + childList.push(child.el); + }, this); + this.$el.html(childList); + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/views/program_card_view.js b/lms/static/js/learner_dashboard/views/program_card_view.js new file mode 100644 index 0000000000..ce3a8650f6 --- /dev/null +++ b/lms/static/js/learner_dashboard/views/program_card_view.js @@ -0,0 +1,30 @@ +;(function (define) { + 'use strict'; + + define(['backbone', + 'jquery', + 'underscore', + 'gettext', + 'text!../../../templates/learner_dashboard/program_card.underscore' + ], + function( + Backbone, + $, + _, + gettext, + programCardTpl + ) { + return Backbone.View.extend({ + className: 'program-card', + tpl: _.template(programCardTpl), + initialize: function() { + this.render(); + }, + render: function() { + var templated = this.tpl(this.model.toJSON()); + this.$el.html(templated); + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/views/sidebar_view.js b/lms/static/js/learner_dashboard/views/sidebar_view.js new file mode 100644 index 0000000000..d373463331 --- /dev/null +++ b/lms/static/js/learner_dashboard/views/sidebar_view.js @@ -0,0 +1,32 @@ +;(function (define) { + 'use strict'; + + define(['backbone', + 'jquery', + 'underscore', + 'gettext', + 'text!../../../templates/learner_dashboard/sidebar.underscore' + ], + function( + Backbone, + $, + _, + gettext, + sidebarTpl + ) { + return Backbone.View.extend({ + el: '.sidebar', + tpl: _.template(sidebarTpl), + initialize: function(data) { + this.context = data.context; + }, + render: function() { + if (this.context.xseriesUrl){ + //Only show the xseries advertising panel if the link is passed in + this.$el.html(this.tpl(this.context)); + } + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js b/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js new file mode 100644 index 0000000000..cda26f6a4e --- /dev/null +++ b/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js @@ -0,0 +1,96 @@ +define([ + 'backbone', + 'jquery', + 'js/learner_dashboard/views/program_card_view', + 'js/learner_dashboard/collections/program_collection', + 'js/learner_dashboard/views/collection_list_view' + ], function (Backbone, $, ProgramCardView, ProgramCollection, CollectionListView) { + + 'use strict'; + /*jslint maxlen: 500 */ + + describe('Collection List View', function () { + var view = null, + programCollection, + context = { + programsData:[ + { + category: 'xseries', + status: 'active', + subtitle: 'program 1', + name: 'test program 1', + organizations: [ + { + display_name: 'edX', + key: 'edx' + } + ], + created: '2016-03-03T19:18:50.061136Z', + modified: '2016-03-25T13:45:21.220732Z', + marketing_slug: 'p_2?param=haha&test=b', + id: 146, + marketing_url: 'http://www.edx.org/xseries/p_2?param=haha&test=b' + }, + { + category: 'xseries', + status: 'active', + subtitle: 'fda', + name: 'fda', + organizations: [ + { + display_name: 'edX', + key: 'edx' + } + ], + created: '2016-03-09T14:30:41.484848Z', + modified: '2016-03-09T14:30:52.840898Z', + marketing_slug: 'gdaf', + id: 147, + marketing_url: 'http://www.edx.org/xseries/gdaf' + } + ] + }; + + beforeEach(function() { + setFixtures('
'); + programCollection = new ProgramCollection(context.programsData); + view = new CollectionListView({ + el: '.program-cards-container', + childView: ProgramCardView, + collection: programCollection + }); + view.render(); + }); + + afterEach(function() { + view.remove(); + }); + + it('should exist', function() { + expect(view).toBeDefined(); + }); + + it('should load the collection items based on passed in collection', function() { + var $cards = view.$el.find('.program-card'); + expect($cards.length).toBe(2); + $cards.each(function(index, el){ + expect($(el).find('.title').html().trim()).toEqual(context.programsData[index].name); + }); + }); + + it('should display no item if collection is empty', function(){ + var $cards; + view.remove(); + programCollection = new ProgramCollection([]); + view = new CollectionListView({ + el: '.program-cards-container', + childView: ProgramCardView, + collection: programCollection + }); + view.render(); + $cards = view.$el.find('.program-card'); + expect($cards.length).toBe(0); + }); + }); + } +); diff --git a/lms/static/js/spec/learner_dashboard/program_card_view_spec.js b/lms/static/js/spec/learner_dashboard/program_card_view_spec.js new file mode 100644 index 0000000000..f4942fc00c --- /dev/null +++ b/lms/static/js/spec/learner_dashboard/program_card_view_spec.js @@ -0,0 +1,58 @@ +define([ + 'backbone', + 'jquery', + 'js/learner_dashboard/views/program_card_view', + 'js/learner_dashboard/models/program_model' + ], function (Backbone, $, ProgramCardView, ProgramModel) { + + 'use strict'; + /*jslint maxlen: 500 */ + + describe('Program card View', function () { + var view = null, + programModel, + program = { + category: 'xseries', + status: 'active', + subtitle: 'program 1', + name: 'test program 1', + organizations: [ + { + display_name: 'edX', + key: 'edx' + } + ], + created: '2016-03-03T19:18:50.061136Z', + modified: '2016-03-25T13:45:21.220732Z', + marketing_slug: 'p_2?param=haha&test=b', + id: 146, + marketing_url: 'http://www.edx.org/xseries/p_2?param=haha&test=b' + }; + + beforeEach(function() { + setFixtures('
'); + programModel = new ProgramModel(program); + view = new ProgramCardView({ + model: programModel + }); + }); + + afterEach(function() { + view.remove(); + }); + + it('should exist', function() { + expect(view).toBeDefined(); + }); + + it('should load the program-cards based on passed in context', function() { + var $cards = view.$el; + expect($cards).toBeDefined(); + expect($cards.find('.title').html().trim()).toEqual(program.name); + expect($cards.find('.category span').html().trim()).toEqual(program.category); + expect($cards.find('.organization span').html().trim()).toEqual(program.organizations[0].display_name); + expect($cards.find('.card-link').attr('href')).toEqual(program.marketing_url); + }); + }); + } +); diff --git a/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js b/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js new file mode 100644 index 0000000000..4b9104e810 --- /dev/null +++ b/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js @@ -0,0 +1,54 @@ +define([ + 'backbone', + 'jquery', + 'js/learner_dashboard/views/sidebar_view' + ], function (Backbone, $, SidebarView) { + + 'use strict'; + /*jslint maxlen: 500 */ + + describe('Sidebar View', function () { + var view = null, + context = { + xseriesUrl: 'http://www.edx.org/xseries' + }; + + beforeEach(function() { + setFixtures(''); + + view = new SidebarView({ + el: '.sidebar', + context: context + }); + view.render(); + }); + + afterEach(function() { + view.remove(); + }); + + it('should exist', function() { + expect(view).toBeDefined(); + }); + + it('should load the xseries advertising based on passed in xseries URL', function() { + var $sidebar = view.$el; + expect($sidebar.find('.program-advertise .advertise-message').html().trim()) + .toEqual('Browse recently launched courses and see what\'s new in our favorite subjects'); + expect($sidebar.find('.program-advertise .ad-link a').attr('href')).toEqual(context.xseriesUrl); + }); + + it('should not load the xseries advertising if no xseriesUrl passed in', function(){ + var $ad; + view.remove(); + view = new SidebarView({ + el: '.sidebar', + context: {} + }); + view.render(); + $ad = view.$el.find('.program-advertise'); + expect($ad.length).toBe(0); + }); + }); + } +); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 97f57f521f..cc72307351 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -738,7 +738,10 @@ 'lms/include/js/spec/bookmarks/bookmarks_list_view_spec.js', 'lms/include/js/spec/bookmarks/bookmark_button_view_spec.js', 'lms/include/js/spec/views/message_banner_spec.js', - 'lms/include/js/spec/markdown_editor_spec.js' + 'lms/include/js/spec/markdown_editor_spec.js', + 'lms/include/js/spec/learner_dashboard/collection_list_view_spec.js', + 'lms/include/js/spec/learner_dashboard/sidebar_view_spec.js', + 'lms/include/js/spec/learner_dashboard/program_card_view_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 767fe9311f..c82b77311f 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -119,6 +119,7 @@ fixture_paths: - support/templates - js/fixtures/bookmarks - templates/bookmarks + - templates/learner_dashboard requirejs: paths: diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index 747097574d..45afb1dfa3 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -33,7 +33,8 @@ 'teams/js/teams_tab_factory', 'support/js/certificates_factory', 'support/js/enrollment_factory', - 'js/bookmarks/bookmarks_factory' + 'js/bookmarks/bookmarks_factory', + 'js/learner_dashboard/program_list_factory' ]), /** diff --git a/lms/static/sass/_build-lms.scss b/lms/static/sass/_build-lms.scss index 88ee8ce3c0..b749e0f28f 100644 --- a/lms/static/sass/_build-lms.scss +++ b/lms/static/sass/_build-lms.scss @@ -17,6 +17,7 @@ @import 'elements/controls'; @import 'elements/pagination'; @import 'elements/creative-commons'; +@import 'elements/program-card'; // shared - course @import 'shared/fields'; @@ -57,6 +58,7 @@ @import "views/financial-assistance"; @import 'views/bookmarks'; @import 'course/auto-cert'; +@import 'views/program-list'; // app - discussion @import "discussion/utilities/variables"; diff --git a/lms/static/sass/elements/_program-card.scss b/lms/static/sass/elements/_program-card.scss new file mode 100644 index 0000000000..77f8d8df9c --- /dev/null +++ b/lms/static/sass/elements/_program-card.scss @@ -0,0 +1,86 @@ +// +Imports +// ==================== +@import '../base/grid-settings'; +@import 'neat/neat'; // lib - Neat + +$card-height: 150px; + +.program-card{ + @include span-columns(12); + height: $card-height; + border:1px solid $border-color-l3; + box-sizing:border-box; + padding: $baseline; + margin-bottom: $baseline; + position:relative; + .card-link{ + position:absolute; + top:0; + bottom:0; + right:0; + left:0; + outline:0; + border:0; + z-index:1; + height: $card-height; + } + .text-section{ + .meta-info{ + @include outer-container; + margin-bottom: $baseline; + font-size: em(12); + .organization{ + @include span-columns(6); + color: $gray; + } + .category{ + @include span-columns(6); + text-align:right; + span{ + @include float(right); + } + .xseries-icon{ + @include float(right); + @include margin-right($baseline*0.2); + background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat; + background-color: transparent; + + width: ($baseline*1); + height: ($baseline*1); + } + } + } + .title{ + font-size:em(30); + color: $gray-l1; + margin-bottom: 10px; + line-height: 100%; + } + } +} + + +@include media($bp-medium) { + .program-card{ + @include span-columns(8); + } +} + + + +@include media($bp-large) { + .program-card{ + @include omega(2n); + @include span-columns(6); + display:inline; + } +} + +@include media($bp-huge) { + .program-card{ + @include omega(2n); + @include span-columns(6); + display:inline; + } +} + diff --git a/lms/static/sass/views/_program-list.scss b/lms/static/sass/views/_program-list.scss new file mode 100644 index 0000000000..75a287962d --- /dev/null +++ b/lms/static/sass/views/_program-list.scss @@ -0,0 +1,74 @@ +// +Imports +// ==================== +@import '../base/grid-settings'; +@import 'neat/neat'; // lib - Neat + +.program-list-wrapper{ + @include outer-container; + padding: $baseline $baseline; +} + +.program-cards-container{ + @include outer-container; + @include span-columns(12); +} +.sidebar{ + @include outer-container; + @include span-columns(12); + .program-advertise{ + padding: $baseline; + background-color: $body-bg; + box-sizing: border-box; + border: 1px solid $border-color-l3; + clear:both; + .advertise-message{ + font-size:em(12); + color: $gray-d4; + margin-bottom: $baseline; + } + .ad-link{ + padding:$baseline * 0.5; + border: 1px solid $blue-t1; + font-size: em(16); + a{ + text-decoration: none; + &:hover, &:focus, &:active{ + background-color: $button-bg-hover-color; + } + } + } + } +} + + + +@include media($bp-medium) { + .program-cards-container{ + @include span-columns(8); + } + .sidebar{ + @include span-columns(8); + } +} + + + +@include media($bp-large) { + .program-cards-container{ + @include span-columns(9); + } + .sidebar{ + @include omega(n); + @include span-columns(3); + } +} + +@include media($bp-huge) { + .program-cards-container{ + @include span-columns(9); + } + .sidebar{ + @include omega(n); + @include span-columns(3); + } +} diff --git a/lms/templates/learner_dashboard/program_card.underscore b/lms/templates/learner_dashboard/program_card.underscore new file mode 100644 index 0000000000..644a704251 --- /dev/null +++ b/lms/templates/learner_dashboard/program_card.underscore @@ -0,0 +1,24 @@ + + +
+
+
+ <% _.each(organizations, function(org){ %> + <%- gettext(org.display_name) %> + <% }); %> +
+
+ <%- gettext(category) %> + +
+
+ +
+
+
diff --git a/lms/templates/learner_dashboard/programs.html b/lms/templates/learner_dashboard/programs.html new file mode 100644 index 0000000000..d563522aa2 --- /dev/null +++ b/lms/templates/learner_dashboard/programs.html @@ -0,0 +1,25 @@ +<%page expression_filter="h"/> +<%inherit file="../main.html" /> +<%namespace name='static' file='../static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.js_utils import ( + dump_js_escaped_json, js_escaped_string +) +%> + +<%block name="js_extra"> +<%static:require_module module_name="js/learner_dashboard/program_list_factory" class_name="ProgramListFactory"> +ProgramListFactory({ + programsData: ${programs | n, dump_js_escaped_json}, + xseriesUrl: '${xseries_url | n, js_escaped_string}' +}); + + + +<%block name="pagetitle">${_("Programs")} + +
+
+ +
diff --git a/lms/templates/learner_dashboard/sidebar.underscore b/lms/templates/learner_dashboard/sidebar.underscore new file mode 100644 index 0000000000..89a1314072 --- /dev/null +++ b/lms/templates/learner_dashboard/sidebar.underscore @@ -0,0 +1,13 @@ +
+ + +
+
diff --git a/lms/urls.py b/lms/urls.py index 74f9eead03..a765df02f9 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -112,6 +112,10 @@ urlpatterns = ( url(r'^verify_student/', include('verify_student.urls')), ) +urlpatterns += ( + url(r'^dashboard/', include('learner_dashboard.urls')), +) + if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]: # Backwards compatibility with old URL structure, but serve the new views urlpatterns += ( diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py index 171d59d805..4872d6c5fb 100644 --- a/openedx/core/djangoapps/credentials/tests/test_utils.py +++ b/openedx/core/djangoapps/credentials/tests/test_utils.py @@ -94,7 +94,7 @@ class TestCredentialsRetrieval(ProgramsApiConfigMixin, CredentialsApiConfigMixin self.mock_credentials_api(self.user, reset_url=False) actual = get_user_program_credentials(self.user) - expected = self.PROGRAMS_API_RESPONSE['results'] + expected = self.PROGRAMS_API_RESPONSE['results'][:2] expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url'] expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url'] diff --git a/openedx/core/djangoapps/programs/migrations/0006_programsapiconfig_xseries_ad_enabled.py b/openedx/core/djangoapps/programs/migrations/0006_programsapiconfig_xseries_ad_enabled.py new file mode 100644 index 0000000000..a41b89b4c5 --- /dev/null +++ b/openedx/core/djangoapps/programs/migrations/0006_programsapiconfig_xseries_ad_enabled.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programs', '0005_programsapiconfig_max_retries'), + ] + + operations = [ + migrations.AddField( + model_name='programsapiconfig', + name='xseries_ad_enabled', + field=models.BooleanField(default=False, verbose_name='Do we want to show xseries program advertising'), + ), + ] diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py index fd880af7f3..ef8fecfcea 100644 --- a/openedx/core/djangoapps/programs/models.py +++ b/openedx/core/djangoapps/programs/models.py @@ -74,6 +74,11 @@ class ProgramsApiConfig(ConfigurationModel): ) ) + xseries_ad_enabled = models.BooleanField( + verbose_name=_("Do we want to show xseries program advertising"), + default=False + ) + @property def internal_api_url(self): """ @@ -132,3 +137,10 @@ class ProgramsApiConfig(ConfigurationModel): certificates for Program completion. """ return self.enabled and self.enable_certification + + @property + def show_xseries_ad(self): + """ + Indicates whether we should show xseries add + """ + return self.enabled and self.xseries_ad_enabled diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py index b78862202e..3c1f710c95 100644 --- a/openedx/core/djangoapps/programs/tests/mixins.py +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -20,6 +20,7 @@ class ProgramsApiConfigMixin(object): 'enable_student_dashboard': True, 'enable_studio_tab': True, 'enable_certification': True, + 'xseries_ad_enabled': True, } def create_programs_config(self, **kwargs): @@ -35,6 +36,7 @@ class ProgramsDataMixin(object): PROGRAM_NAMES = [ 'Test Program A', 'Test Program B', + 'Test Program C', ] COURSE_KEYS = [ @@ -48,6 +50,7 @@ class ProgramsDataMixin(object): 'organization-b/course-d/winter', ] + # TODO: Use factory-boy. PROGRAMS_API_RESPONSE = { 'results': [ { @@ -56,7 +59,7 @@ class ProgramsDataMixin(object): 'subtitle': 'A program used for testing purposes', 'category': 'xseries', 'status': 'unpublished', - 'marketing_slug': '', + 'marketing_slug': '{}_test_url'.format(PROGRAM_NAMES[0].replace(' ', '_')), 'organizations': [ { 'display_name': 'Test Organization A', @@ -122,7 +125,7 @@ class ProgramsDataMixin(object): 'subtitle': 'Another program used for testing purposes', 'category': 'xseries', 'status': 'unpublished', - 'marketing_slug': '', + 'marketing_slug': '{}_test_url'.format(PROGRAM_NAMES[1].replace(' ', '_')), 'organizations': [ { 'display_name': 'Test Organization B', @@ -181,6 +184,41 @@ class ProgramsDataMixin(object): ], 'created': '2015-10-26T19:59:03.064000Z', 'modified': '2015-10-26T19:59:18.536000Z' + }, + { + 'id': 3, + 'name': PROGRAM_NAMES[2], + 'subtitle': 'A third program used for testing purposes', + 'category': 'xseries', + 'status': 'unpublished', + 'marketing_slug': '{}_test_url'.format(PROGRAM_NAMES[2].replace(' ', '_')), + 'organizations': [ + { + 'display_name': 'Test Organization B', + 'key': 'organization-b' + } + ], + 'course_codes': [ + { + 'display_name': 'Test Course D', + 'key': 'course-d', + 'organization': { + 'display_name': 'Test Organization B', + 'key': 'organization-b' + }, + 'run_modes': [ + { + 'course_key': COURSE_KEYS[7], + 'mode_slug': 'verified', + 'sku': '', + 'start_date': '2015-11-05T07:39:02.791741Z', + 'run_key': 'winter' + } + ] + } + ], + 'created': '2015-10-26T19:59:03.064000Z', + 'modified': '2015-10-26T19:59:18.536000Z' } ] } diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index f0f382dc98..a1e2b9f1be 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -14,9 +14,12 @@ from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfi from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin from openedx.core.djangoapps.programs.utils import ( - get_programs, get_programs_for_credentials, get_programs_for_dashboard + get_programs, + get_programs_for_dashboard, + get_programs_for_credentials, + get_engaged_programs, ) -from student.tests.factories import UserFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @@ -146,7 +149,7 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, self.mock_programs_api() actual = get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA) - expected = self.PROGRAMS_API_RESPONSE['results'] + expected = self.PROGRAMS_API_RESPONSE['results'][:2] expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url'] expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url'] @@ -185,3 +188,92 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, ] actual = get_programs_for_credentials(self.user, credential_data) self.assertEqual(actual, []) + + def _create_enrollments(self, *course_ids): + """Variadic helper method used to create course enrollments.""" + return [CourseEnrollmentFactory(user=self.user, course_id=c) for c in course_ids] + + @httpretty.activate + def test_get_engaged_programs(self): + """ + Verify that correct programs are returned in the correct order when the user + has multiple enrollments. + """ + self.create_programs_config() + self.mock_programs_api() + + enrollments = self._create_enrollments(*self.COURSE_KEYS) + actual = get_engaged_programs(self.user, enrollments) + + programs = self.PROGRAMS_API_RESPONSE['results'] + # get_engaged_programs iterates across a list returned by the programs + # API to create flattened lists keyed by course ID. These lists are + # joined in order of enrollment creation time when constructing the + # list of engaged programs. As such, two programs sharing an enrollment + # should be returned in the same order found in the API response. In this + # case, the most recently created enrollment is for a run mode present in + # the last two test programs. + expected = [ + programs[1], + programs[2], + programs[0], + ] + + self.assertEqual(expected, actual) + + @httpretty.activate + def test_get_engaged_programs_single_program(self): + """ + Verify that correct program is returned when the user has a single enrollment + appearing in one program. + """ + self.create_programs_config() + self.mock_programs_api() + + enrollments = self._create_enrollments(self.COURSE_KEYS[0]) + actual = get_engaged_programs(self.user, enrollments) + + programs = self.PROGRAMS_API_RESPONSE['results'] + expected = [programs[0]] + + self.assertEqual(expected, actual) + + @httpretty.activate + def test_get_engaged_programs_shared_enrollment(self): + """ + Verify that correct programs are returned when the user has a single enrollment + appearing in multiple programs. + """ + self.create_programs_config() + self.mock_programs_api() + + enrollments = self._create_enrollments(self.COURSE_KEYS[-1]) + actual = get_engaged_programs(self.user, enrollments) + + programs = self.PROGRAMS_API_RESPONSE['results'] + expected = programs[-2:] + + self.assertEqual(expected, actual) + + @httpretty.activate + def test_get_engaged_no_enrollments(self): + """Verify that no programs are returned when the user has no enrollments.""" + self.create_programs_config() + self.mock_programs_api() + + actual = get_engaged_programs(self.user, []) + expected = [] + + self.assertEqual(expected, actual) + + @httpretty.activate + def test_get_engaged_no_programs(self): + """Verify that no programs are returned when no programs exist.""" + self.create_programs_config() + self.mock_programs_api(data=[]) + + enrollments = self._create_enrollments(*self.COURSE_KEYS) + actual = get_engaged_programs(self.user, enrollments) + expected = [] + + self.assertEqual(expected, actual) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 1744375643..42766ef851 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -25,10 +25,34 @@ def get_programs(user): # Bypass caching for staff users, who may be creating Programs and want # to see them displayed immediately. cache_key = programs_config.CACHE_KEY if programs_config.is_cache_enabled and not user.is_staff else None - return get_edx_api_data(programs_config, user, 'programs', cache_key=cache_key) +def flatten_programs(programs, course_ids): + """Flatten the result returned by the Programs API. + + Arguments: + programs (list): Serialized programs + course_ids (list): Course IDs to key on. + + Returns: + dict, programs keyed by course ID + """ + flattened = {} + + for program in programs: + try: + for course_code in program['course_codes']: + for run in course_code['run_modes']: + run_id = run['course_key'] + if run_id in course_ids: + flattened.setdefault(run_id, []).append(program) + except KeyError: + log.exception('Unable to parse Programs API response: %r', program) + + return flattened + + def get_programs_for_dashboard(user, course_keys): """Build a dictionary of programs, keyed by course. @@ -55,23 +79,8 @@ def get_programs_for_dashboard(user, course_keys): log.debug('No programs found for the user with ID %d.', user.id) return course_programs - # Convert course keys to Unicode representation for efficient lookup. - course_keys = map(unicode, course_keys) - - # Reindex the result returned by the Programs API from: - # program -> course code -> course run - # to: - # course run -> program_array - # Ignore course runs not present in the user's active enrollments. - for program in programs: - try: - for course_code in program['course_codes']: - for run in course_code['run_modes']: - course_key = run['course_key'] - if course_key in course_keys: - course_programs.setdefault(course_key, []).append(program) - except KeyError: - log.exception('Unable to parse Programs API response: %r', program) + course_ids = [unicode(c) for c in course_keys] + course_programs = flatten_programs(programs, course_ids) return course_programs @@ -102,3 +111,30 @@ def get_programs_for_credentials(user, programs_credentials): certificate_programs.append(program) return certificate_programs + + +def get_engaged_programs(user, enrollments): + """Derive a list of programs in which the given user is engaged. + + Arguments: + user (User): The user for which to find programs. + enrollments (list): The user's enrollments. + + Returns: + list of serialized programs, ordered by most recent enrollment + """ + programs = get_programs(user) + + enrollments = sorted(enrollments, key=lambda e: e.created, reverse=True) + # enrollment.course_id is really a course key. + course_ids = [unicode(e.course_id) for e in enrollments] + + flattened = flatten_programs(programs, course_ids) + + engaged_programs = [] + for course_id in course_ids: + for program in flattened.get(course_id, []): + if program not in engaged_programs: + engaged_programs.append(program) + + return engaged_programs