Commit 8196e1a0 by Renzo Lucioni

Allow program listing page to display programs from any category

This work removes most references to XSeries from the LMS in an attempt to be more general. ECOM-5018.
parent 92f3c7ee
...@@ -889,7 +889,7 @@ class AnonymousLookupTable(ModuleStoreTestCase): ...@@ -889,7 +889,7 @@ class AnonymousLookupTable(ModuleStoreTestCase):
self.assertEqual(anonymous_id, anonymous_id_for_user(self.user, course2.id, save=False)) self.assertEqual(anonymous_id, anonymous_id_for_user(self.user, course2.id, save=False))
# TODO: Clean up these tests so that they use program factories. # TODO: Clean up these tests so that they use program factories and don't mention XSeries!
@attr('shard_3') @attr('shard_3')
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt @ddt.ddt
...@@ -907,8 +907,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -907,8 +907,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
self.course_2 = CourseFactory.create() self.course_2 = CourseFactory.create()
self.course_3 = CourseFactory.create() self.course_3 = CourseFactory.create()
self.program_name = 'Testing Program' self.program_name = 'Testing Program'
self.category = 'xseries' self.category = 'XSeries'
self.display_category = 'XSeries'
CourseModeFactory.create( CourseModeFactory.create(
course_id=self.course_1.id, course_id=self.course_1.id,
...@@ -990,8 +989,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -990,8 +989,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
self.assertEqual( self.assertEqual(
{ {
u'edx/demox/Run_1': { u'edx/demox/Run_1': {
'category': 'xseries', 'category': self.category,
'display_category': 'XSeries',
'course_program_list': [{ 'course_program_list': [{
'program_id': 0, 'program_id': 0,
'course_count': len(course_codes), 'course_count': len(course_codes),
...@@ -1150,11 +1148,17 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -1150,11 +1148,17 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
""" """
self.assertContains(response, 'label-xseries-association', count) self.assertContains(response, 'label-xseries-association', count)
self.assertContains(response, 'btn xseries-', count) self.assertContains(response, 'btn xseries-', count)
self.assertContains(response, 'XSeries Program Course', count)
self.assertContains(response, 'XSeries Program: Interested in more courses in this subject?', count) self.assertContains(response, '{category} Program Course'.format(category=self.category), count)
self.assertContains(
response,
'{category} Program: Interested in more courses in this subject?'.format(category=self.category),
count
)
self.assertContains(response, 'View {category} Details'.format(category=self.category), count)
self.assertContains(response, 'This course is 1 of 3 courses in the', count) self.assertContains(response, 'This course is 1 of 3 courses in the', count)
self.assertContains(response, self.program_name, count * 2) self.assertContains(response, self.program_name, count * 2)
self.assertContains(response, 'View XSeries Details', count)
class UserAttributeTests(TestCase): class UserAttributeTests(TestCase):
......
...@@ -120,7 +120,7 @@ from notification_prefs.views import enable_notifications ...@@ -120,7 +120,7 @@ from notification_prefs.views import enable_notifications
from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings
from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard, get_display_category from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers
...@@ -2497,7 +2497,7 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali ...@@ -2497,7 +2497,7 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali
for course_key, programs in course_programs.viewitems(): for course_key, programs in course_programs.viewitems():
for program in programs: for program in programs:
if program.get('status') == 'active' and program.get('category') == 'xseries': if program.get('status') == 'active' and program.get('category') == 'XSeries':
try: try:
programs_for_course = programs_data.setdefault(course_key, {}) programs_for_course = programs_data.setdefault(course_key, {})
programs_for_course.setdefault('course_program_list', []).append({ programs_for_course.setdefault('course_program_list', []).append({
...@@ -2510,7 +2510,6 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali ...@@ -2510,7 +2510,6 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali
).format(program['marketing_slug']) ).format(program['marketing_slug'])
}) })
programs_for_course['category'] = program.get('category') programs_for_course['category'] = program.get('category')
programs_for_course['display_category'] = get_display_category(program)
except KeyError: except KeyError:
log.warning('Program structure is invalid, skipping display: %r', program) log.warning('Program structure is invalid, skipping display: %r', program)
......
...@@ -42,7 +42,6 @@ class ProgramsConfigMixin(object): ...@@ -42,7 +42,6 @@ class ProgramsConfigMixin(object):
'enable_student_dashboard': is_enabled, 'enable_student_dashboard': is_enabled,
'enable_studio_tab': is_enabled, 'enable_studio_tab': is_enabled,
'enable_certification': is_enabled, 'enable_certification': is_enabled,
'xseries_ad_enabled': is_enabled,
'program_listing_enabled': is_enabled, 'program_listing_enabled': is_enabled,
'program_details_enabled': is_enabled, 'program_details_enabled': is_enabled,
}).install() }).install()
...@@ -23,7 +23,6 @@ from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfi ...@@ -23,7 +23,6 @@ from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfi
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests import factories as programs_factories from openedx.core.djangoapps.programs.tests import factories as programs_factories
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.programs.utils import get_display_category
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -65,8 +64,6 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar ...@@ -65,8 +64,6 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar
cls.data = sorted([cls.first_program, cls.second_program], key=cls.program_sort_key) cls.data = sorted([cls.first_program, cls.second_program], key=cls.program_sort_key)
cls.marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').rstrip('/')
def setUp(self): def setUp(self):
super(TestProgramListing, self).setUp() super(TestProgramListing, self).setUp()
...@@ -187,30 +184,19 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar ...@@ -187,30 +184,19 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar
for index, actual_program in enumerate(actual): for index, actual_program in enumerate(actual):
expected_program = self.data[index] expected_program = self.data[index]
self.assert_dict_contains_subset(actual_program, expected_program) self.assert_dict_contains_subset(actual_program, expected_program)
self.assertEqual(
actual_program['display_category'],
get_display_category(expected_program)
)
def test_toggle_xseries_advertising(self): def test_program_discovery(self):
""" """
Verify that when XSeries advertising is disabled, no link to the marketing site Verify that a link to a programs marketing page appears in the response.
appears in the response (and vice versa).
""" """
# Verify the URL is present when advertising is enabled. self.create_programs_config(marketing_path='bar')
self.create_programs_config()
self.mock_programs_api(self.data) self.mock_programs_api(self.data)
response = self.client.get(self.url) marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'bar').rstrip('/')
self.assertContains(response, self.marketing_root)
# Verify the URL is missing when advertising is disabled.
self.create_programs_config(xseries_ad_enabled=False)
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertNotContains(response, self.marketing_root) self.assertContains(response, marketing_root)
def test_links_to_detail_pages(self): def test_links_to_detail_pages(self):
""" """
...@@ -237,7 +223,8 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar ...@@ -237,7 +223,8 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar
) )
# Verify that links to the marketing site are present when detail pages are disabled. # Verify that links to the marketing site are present when detail pages are disabled.
self.create_programs_config(program_details_enabled=False) self.create_programs_config(program_details_enabled=False, marketing_path='bar')
marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'bar').rstrip('/')
response = self.client.get(self.url) response = self.client.get(self.url)
actual = self.load_serialized_data(response, 'programsData') actual = self.load_serialized_data(response, 'programsData')
...@@ -248,7 +235,7 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar ...@@ -248,7 +235,7 @@ class TestProgramListing(ProgramsApiConfigMixin, CredentialsApiConfigMixin, Shar
self.assertEqual( self.assertEqual(
actual_program['detail_url'], actual_program['detail_url'],
'{}/{}'.format(self.marketing_root, expected_program['marketing_slug']) '{}/{}'.format(marketing_root, expected_program['marketing_slug'])
) )
def test_certificates_listed(self): def test_certificates_listed(self):
......
...@@ -5,7 +5,7 @@ from . import views ...@@ -5,7 +5,7 @@ from . import views
urlpatterns = [ urlpatterns = [
url(r'^programs/$', views.view_programs, name='program_listing_view'), url(r'^programs/$', views.program_listing, name='program_listing_view'),
# Matches paths like 'programs/123/' and 'programs/123/foo/', but not 'programs/123/foo/bar/'. # Matches paths like 'programs/123/' and 'programs/123/foo/', but not 'programs/123/foo/bar/'.
url(r'^programs/(?P<program_id>\d+)/[\w\-]*/?$', views.program_details, name='program_details_view'), url(r'^programs/(?P<program_id>\d+)/[\w\-]*/?$', views.program_details, name='program_details_view'),
] ]
...@@ -8,19 +8,16 @@ from django.http import Http404 ...@@ -8,19 +8,16 @@ from django.http import Http404
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from lms.djangoapps.learner_dashboard.utils import strip_course_id, FAKE_COURSE_KEY
from openedx.core.djangoapps.credentials.utils import get_programs_credentials from openedx.core.djangoapps.credentials.utils import get_programs_credentials
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs import utils from openedx.core.djangoapps.programs import utils
from lms.djangoapps.learner_dashboard.utils import (
FAKE_COURSE_KEY,
strip_course_id
)
@login_required @login_required
@require_GET @require_GET
def view_programs(request): def program_listing(request):
"""View programs in which the user is engaged.""" """View a list of programs in which the user is engaged."""
programs_config = ProgramsApiConfig.current() programs_config = ProgramsApiConfig.current()
if not programs_config.show_program_listing: if not programs_config.show_program_listing:
raise Http404 raise Http404
...@@ -28,22 +25,20 @@ def view_programs(request): ...@@ -28,22 +25,20 @@ def view_programs(request):
meter = utils.ProgramProgressMeter(request.user) meter = utils.ProgramProgressMeter(request.user)
programs = meter.engaged_programs programs = meter.engaged_programs
# TODO: Pull 'xseries' string from configuration model. marketing_url = urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/')
marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').rstrip('/')
for program in programs: for program in programs:
program['detail_url'] = utils.get_program_detail_url(program, marketing_root) program['detail_url'] = utils.get_program_detail_url(program, marketing_url)
program['display_category'] = utils.get_display_category(program)
context = { context = {
'credentials': get_programs_credentials(request.user),
'disable_courseware_js': True,
'marketing_url': marketing_url,
'nav_hidden': True,
'programs': programs, 'programs': programs,
'progress': meter.progress, 'progress': meter.progress,
'xseries_url': marketing_root if programs_config.show_xseries_ad else None,
'nav_hidden': True,
'show_program_listing': programs_config.show_program_listing, 'show_program_listing': programs_config.show_program_listing,
'credentials': get_programs_credentials(request.user, category='xseries'), 'uses_pattern_library': True,
'disable_courseware_js': True,
'uses_pattern_library': True
} }
return render_to_response('learner_dashboard/programs.html', context) return render_to_response('learner_dashboard/programs.html', context)
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 133.28 119.8">
<path class="cls-1" d="M27.11,43.21A129,129,0,0,0,40,46.42,59.6,59.6,0,0,0,32.75,66l3.94,0.71A55.79,55.79,0,0,1,44.14,47.2,184,184,0,0,0,74.8,49.87V66.36h4V49.85a185.1,185.1,0,0,0,29.8-2.93,55.69,55.69,0,0,1,7.5,19.59l3.94-.71a59.37,59.37,0,0,0-7.28-19.68q6-1.24,12.27-3a61.23,61.23,0,0,1,11.55,23.39l3.88-1a66.47,66.47,0,0,0-128.74.08l3.88,1A61.24,61.24,0,0,1,27.11,43.21Zm19.46,0.35C56.08,30.42,69.23,23.49,74.8,21V45.89A180.18,180.18,0,0,1,46.57,43.56Zm32.23,2.3V21.35c5.93,2.72,18.28,9.49,27.35,22A181.27,181.27,0,0,1,78.8,45.86Zm43.36-6.07q-5.93,1.59-11.67,2.71a73.59,73.59,0,0,0-24.61-22A62.34,62.34,0,0,1,122.15,39.79ZM67.24,20.36c-7.22,4.05-17.4,11.23-25,22.44-5.51-1.05-9.7-2.15-12.28-2.9A62.33,62.33,0,0,1,67.24,20.36Z" transform="translate(-9.84 -15.73)"/>
<rect class="cls-1" x="9.85" y="131.42" width="133.27" height="4" transform="translate(-10.04 -15.62) rotate(-0.08)"/>
<rect class="cls-1" x="9.85" y="75.42" width="133.27" height="4" transform="translate(-9.96 -15.62) rotate(-0.08)"/>
<polygon class="cls-1" points="59.7 104.96 59.7 70.71 29.96 100.47 0.21 70.71 0.21 104.96 4.21 104.96 4.21 80.37 29.96 106.13 55.7 80.37 55.7 104.96 59.7 104.96"/>
<polygon class="cls-1" points="131.2 104.96 131.2 70.71 101.46 100.47 71.71 70.71 71.71 104.96 75.71 104.96 75.71 80.37 101.46 106.13 127.2 80.37 127.2 104.96 131.2 104.96"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36.06 36.98">
<polygon class="cls-1" points="4.96 16.47 3.06 15.45 1.15 16.44 1.53 14.32 0 12.81 2.13 12.52 3.1 10.59 4.04 12.53 6.16 12.86 4.61 14.34 4.96 16.47"/>
<polygon class="cls-1" points="11.89 36.98 6.99 36.98 6.99 19.89 11.89 16.34 11.89 36.98"/>
<polygon class="cls-1" points="10.34 6.69 10.69 8.81 8.79 7.79 6.88 8.78 7.26 6.66 5.73 5.15 7.86 4.86 8.83 2.94 9.77 4.87 11.89 5.2 10.34 6.69"/>
<rect class="cls-1" x="15.59" y="12.95" width="4.89" height="24.02"/>
<polygon class="cls-1" points="19.56 3.75 19.91 5.87 18.01 4.86 16.1 5.84 16.48 3.73 14.95 2.21 17.08 1.92 18.05 0 18.99 1.94 21.11 2.26 19.56 3.75"/>
<polygon class="cls-1" points="29.06 36.98 24.17 36.98 24.18 15.99 29.06 19.6 29.06 36.98"/>
<polygon class="cls-1" points="27.23 7.79 25.31 8.78 25.7 6.66 24.17 5.15 26.3 4.86 27.26 2.94 28.2 4.87 30.33 5.2 28.77 6.69 29.12 8.81 27.23 7.79"/>
<polygon class="cls-1" points="32.95 15.45 31.04 16.44 31.42 14.32 29.89 12.81 32.02 12.52 32.99 10.59 33.93 12.53 36.06 12.86 34.51 14.35 34.85 16.47 32.95 15.45"/>
</svg>
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
if (data){ if (data){
this.set({ this.set({
name: data.name, name: data.name,
type: data.display_category + ' Program', category: data.category,
subtitle: data.subtitle, subtitle: data.subtitle,
organizations: data.organizations, organizations: data.organizations,
detailUrl: data.detail_url, detailUrl: data.detail_url,
......
...@@ -28,10 +28,12 @@ ...@@ -28,10 +28,12 @@
} }
}).render(); }).render();
new SidebarView({ if ( options.programsData.length ) {
el: '.sidebar', new SidebarView({
context: options el: '.sidebar',
}).render(); context: options
}).render();
}
}; };
}); });
}).call(this, define || RequireJS.define); }).call(this, define || RequireJS.define);
...@@ -28,8 +28,8 @@ ...@@ -28,8 +28,8 @@
var childList; var childList;
if (!this.collection.length) { if (!this.collection.length) {
if (this.context.xseriesUrl) { if (this.context.marketingUrl) {
//Only show the xseries advertising panel if the link is passed in //Only show the advertising panel if the link is passed in
HtmlUtils.setHtml(this.$el, HtmlUtils.template(emptyProgramsListTpl)(this.context)); HtmlUtils.setHtml(this.$el, HtmlUtils.template(emptyProgramsListTpl)(this.context));
} }
} else { } else {
......
...@@ -23,8 +23,8 @@ ...@@ -23,8 +23,8 @@
this.context = data.context; this.context = data.context;
this.$parentEl = $(this.parentEl); this.$parentEl = $(this.parentEl);
if (this.context.xseriesUrl){ if (this.context.marketingUrl){
// Only render if there is an XSeries link // Only render if there is a link
this.render(); this.render();
} else { } else {
/** /**
......
...@@ -19,7 +19,7 @@ define([ ...@@ -19,7 +19,7 @@ define([
"credential_url": "https://credentials.stage.edx.org/credentials/dummy-uuid-2/" "credential_url": "https://credentials.stage.edx.org/credentials/dummy-uuid-2/"
} }
], ],
xseriesImage: "/images/testing.png" sampleCertImageSrc: "/images/programs/sample-cert.png"
} }
}; };
...@@ -45,8 +45,8 @@ define([ ...@@ -45,8 +45,8 @@ define([
expect($(el).html().trim()).toEqual(data.context.certificatesData[index].display_name); expect($(el).html().trim()).toEqual(data.context.certificatesData[index].display_name);
expect($(el).attr('href')).toEqual(data.context.certificatesData[index].credential_url); expect($(el).attr('href')).toEqual(data.context.certificatesData[index].credential_url);
}); });
expect(view.$el.find('.hd-6').html().trim()).toEqual('XSeries Program Certificates:'); expect(view.$el.find('.hd-6').html().trim()).toEqual('Program Certificates');
expect(view.$el.find('img').attr('src')).toEqual('/images/testing.png'); expect(view.$el.find('img').attr('src')).toEqual(data.context.sampleCertImageSrc);
}); });
it('should display no certificate box if certificates list is empty', function() { it('should display no certificate box if certificates list is empty', function() {
......
...@@ -13,8 +13,7 @@ define([ ...@@ -13,8 +13,7 @@ define([
var view = null, var view = null,
programModel, programModel,
program = { program = {
category: 'xseries', category: 'FooBar',
display_category: 'XSeries',
status: 'active', status: 'active',
subtitle: 'program 1', subtitle: 'program 1',
name: 'test program 1', name: 'test program 1',
...@@ -53,7 +52,7 @@ define([ ...@@ -53,7 +52,7 @@ define([
cardRenders = function($card) { cardRenders = function($card) {
expect($card).toBeDefined(); expect($card).toBeDefined();
expect($card.find('.title').html().trim()).toEqual(program.name); expect($card.find('.title').html().trim()).toEqual(program.name);
expect($card.find('.category span').html().trim()).toEqual('XSeries Program'); expect($card.find('.category span').html().trim()).toEqual(program.category);
expect($card.find('.organization').html().trim()).toEqual(program.organizations[0].key); expect($card.find('.organization').html().trim()).toEqual(program.organizations[0].key);
expect($card.find('.card-link').attr('href')).toEqual(program.detail_url); expect($card.find('.card-link').attr('href')).toEqual(program.detail_url);
}; };
......
...@@ -10,14 +10,14 @@ define([ ...@@ -10,14 +10,14 @@ define([
describe('Sidebar View', function () { describe('Sidebar View', function () {
var view = null, var view = null,
context = { context = {
xseriesUrl: 'http://www.edx.org/xseries', marketingUrl: 'https://www.example.org/programs',
certificatesData: [ certificatesData: [
{ {
"display_name": "Testing", "display_name": "Testing",
"credential_url": "https://credentials.stage.edx.org/credentials/dummy-uuid-1/" "credential_url": "https://credentials.example.com/credentials/uuid/"
} }
], ],
xseriesImage: '/image/test.png' sampleCertImageSrc: "/images/programs/sample-cert.png"
}; };
beforeEach(function() { beforeEach(function() {
...@@ -38,18 +38,18 @@ define([ ...@@ -38,18 +38,18 @@ define([
expect(view).toBeDefined(); expect(view).toBeDefined();
}); });
it('should load the xseries advertising based on passed in xseries URL', function() { it('should load the exploration panel given a marketing URL', function() {
var $sidebar = view.$el; var $sidebar = view.$el;
expect($sidebar.find('.program-advertise .advertise-message').html().trim()) expect($sidebar.find('.program-advertise .advertise-message').html().trim())
.toEqual('Browse recently launched courses and see what\'s new in your favorite subjects'); .toEqual('Browse recently launched courses and see what\'s new in your favorite subjects');
expect($sidebar.find('.program-advertise .ad-link a').attr('href')).toEqual(context.xseriesUrl); expect($sidebar.find('.program-advertise .ad-link a').attr('href')).toEqual(context.marketingUrl);
}); });
it('should load the certificates based on passed in certificates list', function() { it('should load the certificates based on passed in certificates list', function() {
expect(view.$('.certificate-link').length).toBe(1); expect(view.$('.certificate-link').length).toBe(1);
}); });
it('should not load the xseries advertising if no xseriesUrl passed in', function(){ it('should not load the advertising panel if no marketing URL is provided', function(){
var $ad; var $ad;
view.remove(); view.remove();
view = new SidebarView({ view = new SidebarView({
......
...@@ -58,7 +58,6 @@ ...@@ -58,7 +58,6 @@
@import "views/financial-assistance"; @import "views/financial-assistance";
@import 'views/bookmarks'; @import 'views/bookmarks';
@import 'course/auto-cert'; @import 'course/auto-cert';
@import 'elements/xseries-certificates';
@import 'views/api-access'; @import 'views/api-access';
// app - discussion // app - discussion
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
overflow: hidden; overflow: hidden;
} }
.program-card{ .program-card {
@include span(12); @include span(12);
border: 1px solid $border-color-l3; border: 1px solid $border-color-l3;
border-bottom: none; border-bottom: none;
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
} }
} }
.card-link{ .card-link {
@include left(0); @include left(0);
@include right(0); @include right(0);
position: absolute; position: absolute;
...@@ -33,11 +33,11 @@ ...@@ -33,11 +33,11 @@
&:active, &:active,
&:hover, &:hover,
&:focus{ &:focus {
opacity: 1; opacity: 1;
} }
.banner-image-container{ .banner-image-container {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
height: 166px; height: 166px;
...@@ -46,7 +46,7 @@ ...@@ -46,7 +46,7 @@
@include susy-media($bp-screen-md) { height: 116px; } @include susy-media($bp-screen-md) { height: 116px; }
@include susy-media($bp-screen-lg) { height: 145px; } @include susy-media($bp-screen-lg) { height: 145px; }
.banner-image{ .banner-image {
@include left(50%); @include left(50%);
position: absolute; position: absolute;
top: 0; top: 0;
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
} }
} }
.text-section{ .text-section {
padding: 40px $baseline $baseline; padding: 40px $baseline $baseline;
position: relative; position: relative;
margin-top: 156px; margin-top: 156px;
...@@ -67,7 +67,7 @@ ...@@ -67,7 +67,7 @@
@include susy-media($bp-screen-lg) { margin-top: 135px; } @include susy-media($bp-screen-lg) { margin-top: 135px; }
} }
.meta-info{ .meta-info {
font-size: font-size(x-small); font-size: font-size(x-small);
color: palette(grayscale, dark); color: palette(grayscale, dark);
position: absolute; position: absolute;
...@@ -75,31 +75,39 @@ ...@@ -75,31 +75,39 @@
width: calc(100% - 40px); width: calc(100% - 40px);
} }
.organization{ .organization {
@include span(6); @include span(6);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.category{ .category {
@include span(6); @include span(6);
@include text-align(right); @include text-align(right);
.category-text{ .category-text {
@include float(right); @include float(right);
} }
.xseries-icon{ .category-icon {
@include float(right); @include float(right);
@include margin-right($baseline*0.25); @include margin-right($baseline*0.25);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent; background-color: transparent;
background-size: 100%; background-size: 100%;
width: ($baseline*0.7); width: ($baseline*0.7);
height: ($baseline*0.7); height: ($baseline*0.7);
} }
.xseries-icon{
background: url('#{$static-path}/images/programs/xseries-icon.svg') no-repeat;
}
.micromasters-icon{
margin-top: $baseline * 0.05;
background: url('#{$static-path}/images/programs/micromasters-icon.svg') no-repeat;
}
} }
.hd-3 { .hd-3 {
color: palette(grayscale, x-dark); color: palette(grayscale, x-dark);
min-height: ($baseline*3); min-height: ($baseline*3);
......
@mixin xseries-certificate-container {
border: 1px solid $gray-l3;
box-sizing: border-box;
padding: $baseline;
background: $gray-l6;
margin-top: $baseline;
.title{
@extend %t-title6;
@extend %t-weight3;
margin-bottom:$baseline;
color: $gray;
}
.certificate-link{
padding-top: $baseline;
display: block;
}
}
...@@ -77,32 +77,4 @@ ...@@ -77,32 +77,4 @@
color: $black; color: $black;
margin-bottom: $baseline; margin-bottom: $baseline;
} }
.find-xseries-programs {
background: $black;
border-color: $black;
color: $white;
.action-xseries-icon {
@include float(left);
@include margin-right($baseline*0.4);
display: inline;
background: url('#{$static-path}/images/icon-sm-xseries-white.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
&:active,
&:hover,
&:focus {
background: $white;
color: $black;
.action-xseries-icon {
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
}
}
}
} }
...@@ -53,10 +53,10 @@ from student.helpers import ( ...@@ -53,10 +53,10 @@ from student.helpers import (
<% mode_class = '' %> <% mode_class = '' %>
% endif % endif
<div class="course-container"> <div class="course-container">
% if course_program_info and course_program_info.get('category')=='xseries': % if course_program_info and course_program_info.get('category')=='XSeries':
<div class="label-xseries-association"> <div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span> <span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">${_("{category} Program Course").format(category=course_program_info['display_category'])}</p> <p class="message-copy">${_("{category} Program Course").format(category=course_program_info['category'])}</p>
</div> </div>
% endif % endif
<article class="course${mode_class}"> <article class="course${mode_class}">
...@@ -370,9 +370,9 @@ from student.helpers import ( ...@@ -370,9 +370,9 @@ from student.helpers import (
</div> </div>
%endif %endif
% if course_program_info and course_program_info.get('category')=='xseries': % if course_program_info and course_program_info.get('category'):
%for program_data in course_program_info.get('course_program_list', []): %for program_data in course_program_info.get('course_program_list', []):
<%include file = "_dashboard_xseries_info.html" args="program_data=program_data, enrollment_mode=enrollment.mode, display_category=course_program_info['display_category']" /> <%include file = "_dashboard_program_info.html" args="program_data=program_data, enrollment_mode=enrollment.mode, category=course_program_info['category']" />
%endfor %endfor
% endif % endif
......
<%page expression_filter="h" args="program_data, enrollment_mode, display_category" /> <%page expression_filter="h" args="program_data, enrollment_mode, category" />
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.markup import HTML, Text
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<div class="xseries-action"> <div class="xseries-action">
<div class="message-copy xseries-msg"> <div class="message-copy xseries-msg">
<p class="message-copy-bold"> <p class="message-copy-bold">
${_("{category} Program: Interested in more courses in this subject?").format(category=display_category)} ${_("{category} Program: Interested in more courses in this subject?").format(category=category)}
</p> </p>
<p class="message-copy"> <p class="message-copy">
${Text(_("This course is 1 of {course_count} courses in the {link_start}{program_display_name}{link_end} {program_category}.")).format( ${Text(_("This course is 1 of {course_count} courses in the {link_start}{program_display_name}{link_end} {program_category}.")).format(
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
link_start=HTML('<a href="{}">').format(program_data['program_marketing_url']), link_start=HTML('<a href="{}">').format(program_data['program_marketing_url']),
link_end=HTML('</a>'), link_end=HTML('</a>'),
program_display_name=program_data['display_name'], program_display_name=program_data['display_name'],
program_category=display_category, program_category=category,
)} )}
</p> </p>
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
data-program-id="${program_data['program_id']}" > data-program-id="${program_data['program_id']}" >
<span class="sr">${program_data['display_name']}</span> <span class="sr">${program_data['display_name']}</span>
<span class="action-xseries-icon" aria-hidden="true"></span> <span class="action-xseries-icon" aria-hidden="true"></span>
${_("View {category} Details").format(category=display_category)} ${_("View {category} Details").format(category=category)}
</a> </a>
</div> </div>
</div> </div>
<div class="certificate-container"> <div class="certificate-container">
<h2 class="hd-6"><%- gettext('XSeries Program Certificates') %>:</h2> <h2 class="hd-6"><%- gettext('Program Certificates') %></h2>
<img src="<%- xseriesImage %>" alt=""> <img src="<%- sampleCertImageSrc %>" alt="">
<% _.each(certificatesData, function(certificate){ %> <% _.each(certificatesData, function(certificate){ %>
<a class="certificate-link" href="<%- gettext(certificate.credential_url) %>"><%- gettext(certificate.display_name) %></a> <a class="certificate-link" href="<%- gettext(certificate.credential_url) %>"><%- gettext(certificate.display_name) %></a>
<% }); %> <% }); %>
......
<section class="empty-programs-message"> <section class="empty-programs-message">
<h2 class="hd-3"><%- gettext('You are not enrolled in any XSeries Programs yet.') %></h2> <h2 class="hd-3"><%- gettext('You are not enrolled in any programs yet.') %></h2>
<a class="btn-neutral find-xseries-programs" href="<%- xseriesUrl %>"> <a class="btn-neutral" href="<%- marketingUrl %>">
<span class="action-xseries-icon" aria-hidden="true"></span> <span class="icon fa fa-search" aria-hidden="true"></span>
<span><%- gettext('Explore XSeries Programs') %></span> <span><%- gettext('Explore Programs') %></span>
</a> </a>
</section> </section>
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
<%- gettext('Browse recently launched courses and see what\'s new in your favorite subjects') %> <%- gettext('Browse recently launched courses and see what\'s new in your favorite subjects') %>
</div> </div>
<div class="ad-link"> <div class="ad-link">
<a href="<%- xseriesUrl %>" class="btn-neutral"> <a href="<%- marketingUrl %>" class="btn-neutral">
<span class="icon fa fa-search" aria-hidden="true"></span> <span class="icon fa fa-search" aria-hidden="true"></span>
<span><%- gettext('Explore New XSeries') %></span> <span><%- gettext('Explore New Programs') %></span>
</a> </a>
</div> </div>
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
<div class="meta-info grid-container"> <div class="meta-info grid-container">
<div class="organization col"><%- orgList %></div> <div class="organization col"><%- orgList %></div>
<div class="category col col-last"> <div class="category col col-last">
<span class="category-text"><%- gettext(type) %></span> <span class="category-text"><%- gettext(category) %></span>
<span class="xseries-icon" aria-hidden="true"></span> <span class="category-icon <%- category.toLowerCase() %>-icon" aria-hidden="true"></span>
</div> </div>
</div> </div>
<% if (progress) { %> <% if (progress) { %>
......
...@@ -14,11 +14,11 @@ from openedx.core.djangolib.js_utils import ( ...@@ -14,11 +14,11 @@ from openedx.core.djangolib.js_utils import (
<%block name="js_extra"> <%block name="js_extra">
<%static:require_module module_name="js/learner_dashboard/program_list_factory" class_name="ProgramListFactory"> <%static:require_module module_name="js/learner_dashboard/program_list_factory" class_name="ProgramListFactory">
ProgramListFactory({ ProgramListFactory({
programsData: ${programs | n, dump_js_escaped_json},
certificatesData: ${credentials | n, dump_js_escaped_json}, certificatesData: ${credentials | n, dump_js_escaped_json},
userProgress: ${progress | n, dump_js_escaped_json}, marketingUrl: '${marketing_url | n, js_escaped_string}',
xseriesUrl: '${xseries_url | n, js_escaped_string}', programsData: ${programs | n, dump_js_escaped_json},
xseriesImage: '${static.url('images/xseries-certificate-visual.png') | n, js_escaped_string}' sampleCertImageSrc: '${static.url('images/programs/sample-cert.png') | n, js_escaped_string}',
userProgress: ${progress | n, dump_js_escaped_json}
}); });
</%static:require_module> </%static:require_module>
</%block> </%block>
......
...@@ -176,44 +176,9 @@ class TestCredentialsRetrieval(ProgramsApiConfigMixin, CredentialsApiConfigMixin ...@@ -176,44 +176,9 @@ class TestCredentialsRetrieval(ProgramsApiConfigMixin, CredentialsApiConfigMixin
# Mocking the API responses from programs and credentials # Mocking the API responses from programs and credentials
self.mock_programs_api() self.mock_programs_api()
self.mock_credentials_api(self.user, reset_url=False) self.mock_credentials_api(self.user, reset_url=False)
actual = get_programs_credentials(self.user, category='xseries')
expected = self.expected_credentials_display_data()
# Checking result is as expected
self.assertEqual(len(actual), 2)
self.assertEqual(actual, expected)
@httpretty.activate
def test_get_programs_credentials_category(self):
""" Verify behaviour when program category is provided."""
# create credentials and program configuration
self.create_credentials_config()
self.create_programs_config()
# Mocking the API responses from programs and credentials
self.mock_programs_api()
self.mock_credentials_api(self.user, reset_url=False)
actual = get_programs_credentials(self.user, category='dummy_category')
expected = self.expected_credentials_display_data()
self.assertEqual(len(actual), 0)
actual = get_programs_credentials(self.user, category='xseries')
self.assertEqual(len(actual), 2)
self.assertEqual(actual, expected)
@httpretty.activate
def test_get_programs_credentials_no_category(self):
""" Verify behaviour when no program category is provided. """
self.create_credentials_config()
self.create_programs_config()
# Mocking the API responses from programs and credentials
self.mock_programs_api()
self.mock_credentials_api(self.user, reset_url=False)
actual = get_programs_credentials(self.user) actual = get_programs_credentials(self.user)
expected = self.expected_credentials_display_data() expected = self.expected_credentials_display_data()
# Checking result is as expected
self.assertEqual(len(actual), 2) self.assertEqual(len(actual), 2)
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -66,7 +66,7 @@ def get_user_program_credentials(user): ...@@ -66,7 +66,7 @@ def get_user_program_credentials(user):
return programs_credentials_data return programs_credentials_data
def get_programs_credentials(user, category=None): def get_programs_credentials(user):
"""Return program credentials data required for display. """Return program credentials data required for display.
Given a user, find all programs for which certificates have been earned Given a user, find all programs for which certificates have been earned
...@@ -74,7 +74,6 @@ def get_programs_credentials(user, category=None): ...@@ -74,7 +74,6 @@ def get_programs_credentials(user, category=None):
Arguments: Arguments:
user (User): user object for getting programs credentials. user (User): user object for getting programs credentials.
category(str) : program category for getting credentials.
Returns: Returns:
list of dict, containing data corresponding to the programs for which list of dict, containing data corresponding to the programs for which
...@@ -83,16 +82,14 @@ def get_programs_credentials(user, category=None): ...@@ -83,16 +82,14 @@ def get_programs_credentials(user, category=None):
programs_credentials = get_user_program_credentials(user) programs_credentials = get_user_program_credentials(user)
credentials_data = [] credentials_data = []
for program in programs_credentials: for program in programs_credentials:
is_included = (category is None) or (program.get('category') == category) try:
if is_included: program_data = {
try: 'display_name': program['name'],
program_data = { 'subtitle': program['subtitle'],
'display_name': program['name'], 'credential_url': program['credential_url'],
'subtitle': program['subtitle'], }
'credential_url': program['credential_url'], credentials_data.append(program_data)
} except KeyError:
credentials_data.append(program_data) log.warning('Program structure is invalid: %r', program)
except KeyError:
log.warning('Program structure is invalid: %r', program)
return credentials_data return credentials_data
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('programs', '0008_programsapiconfig_program_details_enabled'),
]
operations = [
migrations.AddField(
model_name='programsapiconfig',
name='marketing_path',
field=models.CharField(help_text='Path used to construct URLs to programs marketing pages (e.g., "/foo").', max_length=255, blank=True),
),
]
...@@ -21,6 +21,14 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -21,6 +21,14 @@ class ProgramsApiConfig(ConfigurationModel):
internal_service_url = models.URLField(verbose_name=_("Internal Service URL")) internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
public_service_url = models.URLField(verbose_name=_("Public Service URL")) public_service_url = models.URLField(verbose_name=_("Public Service URL"))
marketing_path = models.CharField(
max_length=255,
blank=True,
help_text=_(
'Path used to construct URLs to programs marketing pages (e.g., "/foo").'
)
)
# TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995 # TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995
authoring_app_js_path = models.CharField( authoring_app_js_path = models.CharField(
verbose_name=_("Path to authoring app's JS"), verbose_name=_("Path to authoring app's JS"),
...@@ -73,6 +81,7 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -73,6 +81,7 @@ class ProgramsApiConfig(ConfigurationModel):
) )
) )
# TODO: Remove unused field.
xseries_ad_enabled = models.BooleanField( xseries_ad_enabled = models.BooleanField(
verbose_name=_("Do we want to show xseries program advertising"), verbose_name=_("Do we want to show xseries program advertising"),
default=False default=False
...@@ -132,13 +141,6 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -132,13 +141,6 @@ class ProgramsApiConfig(ConfigurationModel):
return self.enabled and self.enable_certification return self.enabled and self.enable_certification
@property @property
def show_xseries_ad(self):
"""
Indicates whether we should show xseries add
"""
return self.enabled and self.xseries_ad_enabled
@property
def show_program_listing(self): def show_program_listing(self):
""" """
Indicates whether we want to show program listing page Indicates whether we want to show program listing page
......
...@@ -13,7 +13,7 @@ class Program(factory.Factory): ...@@ -13,7 +13,7 @@ class Program(factory.Factory):
id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name
name = FuzzyText(prefix='Program ') name = FuzzyText(prefix='Program ')
subtitle = FuzzyText(prefix='Subtitle ') subtitle = FuzzyText(prefix='Subtitle ')
category = 'xseries' category = 'FooBar'
status = 'unpublished' status = 'unpublished'
marketing_slug = FuzzyText(prefix='slug_') marketing_slug = FuzzyText(prefix='slug_')
organizations = [] organizations = []
......
...@@ -19,9 +19,9 @@ class ProgramsApiConfigMixin(object): ...@@ -19,9 +19,9 @@ class ProgramsApiConfigMixin(object):
'enable_student_dashboard': True, 'enable_student_dashboard': True,
'enable_studio_tab': True, 'enable_studio_tab': True,
'enable_certification': True, 'enable_certification': True,
'xseries_ad_enabled': True,
'program_listing_enabled': True, 'program_listing_enabled': True,
'program_details_enabled': True, 'program_details_enabled': True,
'marketing_path': 'foo',
} }
def create_programs_config(self, **kwargs): def create_programs_config(self, **kwargs):
......
...@@ -93,24 +93,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential ...@@ -93,24 +93,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
# Verify the API was actually hit (not the cache). # Verify the API was actually hit (not the cache).
self.assertEqual(len(httpretty.httpretty.latest_requests), 1) self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
@ddt.data(True, False)
def test_get_programs_category_casing(self, is_detail):
"""Temporary. Verify that program categories are lowercased."""
self.create_programs_config()
program = factories.Program(category='camelCase')
if is_detail:
program_id = program['id']
self.mock_programs_api(data=program, program_id=program_id)
data = utils.get_programs(self.user, program_id=program_id)
self.assertEqual(data['category'], 'camelcase')
else:
self.mock_programs_api(data={'results': [program]})
data = utils.get_programs(self.user)
self.assertEqual(data[0]['category'], 'camelcase')
def test_get_programs_caching(self): def test_get_programs_caching(self):
"""Verify that when enabled, the cache is used for non-staff users.""" """Verify that when enabled, the cache is used for non-staff users."""
self.create_programs_config(cache_ttl=1) self.create_programs_config(cache_ttl=1)
...@@ -235,18 +217,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential ...@@ -235,18 +217,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
actual = utils.get_programs_for_credentials(self.user, credential_data) actual = utils.get_programs_for_credentials(self.user, credential_data)
self.assertEqual(actual, []) self.assertEqual(actual, [])
def test_get_display_category_success(self):
self.create_programs_config()
self.mock_programs_api()
actual_programs = utils.get_programs(self.user)
for program in actual_programs:
expected = 'XSeries'
self.assertEqual(expected, utils.get_display_category(program))
def test_get_display_category_none(self):
self.assertEqual('', utils.get_display_category(None))
self.assertEqual('', utils.get_display_category({"id": "test"}))
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class GetCompletedCoursesTestCase(TestCase): class GetCompletedCoursesTestCase(TestCase):
......
...@@ -49,16 +49,7 @@ def get_programs(user, program_id=None): ...@@ -49,16 +49,7 @@ def get_programs(user, program_id=None):
# to see them displayed immediately. # to see them displayed immediately.
cache_key = programs_config.CACHE_KEY if programs_config.is_cache_enabled and not user.is_staff else None cache_key = programs_config.CACHE_KEY if programs_config.is_cache_enabled and not user.is_staff else None
data = get_edx_api_data(programs_config, user, 'programs', resource_id=program_id, cache_key=cache_key) return get_edx_api_data(programs_config, user, 'programs', resource_id=program_id, cache_key=cache_key)
# TODO: Temporary, to be removed once category names are cased for display. ECOM-5018.
if data and program_id:
data['category'] = data['category'].lower()
else:
for program in data:
program['category'] = program['category'].lower()
return data
def flatten_programs(programs, course_ids): def flatten_programs(programs, course_ids):
...@@ -151,7 +142,7 @@ def get_program_detail_url(program, marketing_root): ...@@ -151,7 +142,7 @@ def get_program_detail_url(program, marketing_root):
Arguments: Arguments:
program (dict): Representation of a program. program (dict): Representation of a program.
marketing_root (str): Root URL used to build links to XSeries marketing pages. marketing_root (str): Root URL used to build links to program marketing pages.
Returns: Returns:
str, a link to program details str, a link to program details
...@@ -166,24 +157,6 @@ def get_program_detail_url(program, marketing_root): ...@@ -166,24 +157,6 @@ def get_program_detail_url(program, marketing_root):
return '{base}/{slug}'.format(base=base, slug=slug) return '{base}/{slug}'.format(base=base, slug=slug)
def get_display_category(program):
""" Given the program, return the category of the program for display
Arguments:
program (Program): The program to get the display category string from
Returns:
string, the category for display to the user.
Empty string if the program has no category or is null.
"""
display_candidate = ''
if program and program.get('category'):
if program.get('category') == 'xseries':
display_candidate = 'XSeries'
else:
display_candidate = program.get('category', '').capitalize()
return display_candidate
def get_completed_courses(student): def get_completed_courses(student):
""" """
Determine which courses have been completed by the user. Determine which courses have been completed by the user.
......
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