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:
23
lms/djangoapps/learner_dashboard/README.rst
Normal file
23
lms/djangoapps/learner_dashboard/README.rst
Normal 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.
|
||||
0
lms/djangoapps/learner_dashboard/__init__.py
Normal file
0
lms/djangoapps/learner_dashboard/__init__.py
Normal file
0
lms/djangoapps/learner_dashboard/tests/__init__.py
Normal file
0
lms/djangoapps/learner_dashboard/tests/__init__.py
Normal file
141
lms/djangoapps/learner_dashboard/tests/test_programs.py
Normal file
141
lms/djangoapps/learner_dashboard/tests/test_programs.py
Normal 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
|
||||
10
lms/djangoapps/learner_dashboard/urls.py
Normal file
10
lms/djangoapps/learner_dashboard/urls.py
Normal 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'),
|
||||
]
|
||||
36
lms/djangoapps/learner_dashboard/views.py
Normal file
36
lms/djangoapps/learner_dashboard/views.py
Normal 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
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
24
lms/static/js/learner_dashboard/models/program_model.js
Normal file
24
lms/static/js/learner_dashboard/models/program_model.js
Normal 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);
|
||||
24
lms/static/js/learner_dashboard/program_list_factory.js
Normal file
24
lms/static/js/learner_dashboard/program_list_factory.js
Normal 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);
|
||||
@@ -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);
|
||||
30
lms/static/js/learner_dashboard/views/program_card_view.js
Normal file
30
lms/static/js/learner_dashboard/views/program_card_view.js
Normal 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);
|
||||
32
lms/static/js/learner_dashboard/views/sidebar_view.js
Normal file
32
lms/static/js/learner_dashboard/views/sidebar_view.js
Normal 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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
54
lms/static/js/spec/learner_dashboard/sidebar_view_spec.js
Normal file
54
lms/static/js/spec/learner_dashboard/sidebar_view_spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
86
lms/static/sass/elements/_program-card.scss
Normal file
86
lms/static/sass/elements/_program-card.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
74
lms/static/sass/views/_program-list.scss
Normal file
74
lms/static/sass/views/_program-list.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
24
lms/templates/learner_dashboard/program_card.underscore
Normal file
24
lms/templates/learner_dashboard/program_card.underscore
Normal 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>
|
||||
25
lms/templates/learner_dashboard/programs.html
Normal file
25
lms/templates/learner_dashboard/programs.html
Normal 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>
|
||||
13
lms/templates/learner_dashboard/sidebar.underscore
Normal file
13
lms/templates/learner_dashboard/sidebar.underscore
Normal 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>
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user