Merge pull request #11879 from edx/schen/ECOM-3194

ECOM-3194 Create the listing page of Programs (x-series) which learners have enrolled in
This commit is contained in:
Renzo Lucioni
2016-03-31 16:35:54 -04:00
32 changed files with 1023 additions and 26 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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'),
]

View File

@@ -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
})

View File

@@ -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"

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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('<div class="program-cards-container"></div>');
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);
});
});
}
);

View File

@@ -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('<div class="program-card"></div>');
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);
});
});
}
);

View File

@@ -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('<div class="sidebar"></div>');
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);
});
});
}
);

View File

@@ -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);

View File

@@ -119,6 +119,7 @@ fixture_paths:
- support/templates
- js/fixtures/bookmarks
- templates/bookmarks
- templates/learner_dashboard
requirejs:
paths:

View File

@@ -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'
]),
/**

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,24 @@
<div class="banner-image">
<a href="<%- marketingUrl %>" class="card-link">
<img alt="<%- gettext(name)%>" src="" />
</a>
</div>
<div class="text-section">
<div class="meta-info">
<div class="organization">
<% _.each(organizations, function(org){ %>
<span><%- gettext(org.display_name) %></span>
<% }); %>
</div>
<div class="category">
<span><%- gettext(category) %></span>
<i class="xseries-icon" aria-hidden="true"></i>
</div>
</div>
<div class="title" aria-hidden="true">
<%- gettext(name) %>
</div>
</div>
<div class="progress">
</div>

View File

@@ -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}'
});
</%static:require_module>
</%block>
<%block name="pagetitle">${_("Programs")}</%block>
<div class="program-list-wrapper">
<div class="program-cards-container"></div>
<div class="sidebar"></div>
</div>

View File

@@ -0,0 +1,13 @@
<div class="program-advertise">
<div class="advertise-message">
<%- gettext('Browse recently launched courses and see what\'s new in our favorite subjects') %>
</div>
<div class="ad-link">
<a href="<%- xseriesUrl %>" class="btn">
<i class="icon fa fa-search" aria-hidden="true"></i>
<span><%- gettext('Explore New XSeries') %></span>
</a>
</div
</div>
<div class="certificate-container">
</div>

View File

@@ -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 += (