Commit dc25ece0 by Renzo Lucioni

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

ECOM-3194 Create the listing page of Programs (x-series) which learners have enrolled in
parents d93e4c33 21be7204
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.
"""
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
"""
Learner's Dashboard urls
"""
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^programs/$', views.view_programs, name='program_listing_view'),
]
"""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
})
......@@ -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"
......
(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);
/**
* 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);
;(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);
;(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);
;(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);
;(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);
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);
});
});
}
);
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);
});
});
}
);
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);
});
});
}
);
......@@ -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);
......@@ -119,6 +119,7 @@ fixture_paths:
- support/templates
- js/fixtures/bookmarks
- templates/bookmarks
- templates/learner_dashboard
requirejs:
paths:
......
......@@ -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'
]),
/**
......
......@@ -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";
......
// +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;
}
}
// +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);
}
}
<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>
<%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>
<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>
......@@ -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 += (
......
......@@ -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']
......
# -*- 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'),
),
]
......@@ -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
......@@ -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'
}
]
}
......
......@@ -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)
......@@ -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
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