Commit 9c81ba47 by Renzo Lucioni

Display programs from all categories on the student dashboard

Removes most remaining hardcoded references to XSeries from the LMS. Part of ECOM-4638.
parent 1c81bd14
...@@ -16,9 +16,8 @@ from openedx.core.lib.token_utils import JwtBuilder ...@@ -16,9 +16,8 @@ from openedx.core.lib.token_utils import JwtBuilder
class ProgramAuthoringView(View): class ProgramAuthoringView(View):
"""View rendering a template which hosts the Programs authoring app. """View rendering a template which hosts the Programs authoring app.
The Programs authoring app is a Backbone SPA maintained in a separate repository. The Programs authoring app is a Backbone SPA. The app handles its own routing
The app handles its own routing and provides a UI which can be used to create and and provides a UI which can be used to create and publish new Programs.
publish new Programs (e.g, XSeries).
""" """
@method_decorator(login_required) @method_decorator(login_required)
......
...@@ -2,53 +2,56 @@ ...@@ -2,53 +2,56 @@
""" """
Miscellaneous tests for the student app. Miscellaneous tests for the student app.
""" """
from datetime import datetime, timedelta
import json
import logging import logging
import unittest import unittest
import ddt
from datetime import datetime, timedelta
from urlparse import urljoin from urlparse import urljoin
import pytz import ddt
from markupsafe import escape
from mock import Mock, patch
from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from pyquery import PyQuery as pq
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.models import User, AnonymousUser
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from edx_oauth2_provider.tests.factories import ClientFactory
import httpretty
from markupsafe import escape
from mock import Mock, patch
from nose.plugins.attrib import attr
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from provider.constants import CONFIDENTIAL
from pyquery import PyQuery as pq
import pytz
from bulk_email.models import Optout # pylint: disable=import-error
from certificates.models import CertificateStatuses # pylint: disable=import-error
from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
from config_models.models import cache
from course_modes.models import CourseMode from course_modes.models import CourseMode
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
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.mixins import ProgramsApiConfigMixin
import shoppingcart # pylint: disable=import-error
from student.models import ( from student.models import (
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment,
unique_id_for_user, LinkedInAddToProfileConfiguration, UserAttribute unique_id_for_user, LinkedInAddToProfileConfiguration, UserAttribute
) )
from student.tests.factories import UserFactory, CourseModeFactory, CourseEnrollmentFactory
from student.views import ( from student.views import (
process_survey_link, process_survey_link,
_cert_info, _cert_info,
complete_course_mode_info, complete_course_mode_info,
_get_course_programs
) )
from student.tests.factories import UserFactory, CourseModeFactory
from util.testing import EventTestMixin
from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME from util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from util.testing import EventTestMixin
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase,
ModuleStoreEnum,
SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ModuleStoreEnum
# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
from bulk_email.models import Optout # pylint: disable=import-error
from certificates.models import CertificateStatuses # pylint: disable=import-error
from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
import shoppingcart # pylint: disable=import-error
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -889,276 +892,95 @@ class AnonymousLookupTable(ModuleStoreTestCase): ...@@ -889,276 +892,95 @@ 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 and don't mention XSeries!
@attr(shard=3) @attr(shard=3)
@httpretty.activate
@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 class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): """Tests verifying that related programs appear on the course dashboard."""
""" url = None
Tests for dashboard for xseries program courses. Enroll student into maxDiff = None
programs and then try different combinations to see xseries upsell password = 'test'
messages are appearing. related_programs_preface = 'Related Programs'
"""
@classmethod
def setUpClass(cls):
super(RelatedProgramsTests, cls).setUpClass()
cls.user = UserFactory()
cls.course = CourseFactory()
cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
cls.organization = programs_factories.Organization()
run_mode = programs_factories.RunMode(course_key=unicode(cls.course.id)) # pylint: disable=no-member
course_code = programs_factories.CourseCode(run_modes=[run_mode])
cls.programs = [
programs_factories.Program(
organizations=[cls.organization],
course_codes=[course_code]
) for __ in range(2)
]
def setUp(self): def setUp(self):
super(DashboardTestXSeriesPrograms, self).setUp() super(RelatedProgramsTests, self).setUp()
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test') self.url = reverse('dashboard')
self.course_1 = CourseFactory.create()
self.course_2 = CourseFactory.create()
self.course_3 = CourseFactory.create()
self.program_name = 'Testing Program'
self.category = 'XSeries'
CourseModeFactory.create( self.create_programs_config()
course_id=self.course_1.id, self.client.login(username=self.user.username, password=self.password)
mode_slug='verified',
mode_display_name='Verified',
expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1)
)
self.client = Client()
cache.clear()
def _create_program_data(self, data): def mock_programs_api(self, data):
"""Dry method to create testing programs data.""" """Helper for mocking out Programs API URLs."""
programs = {} self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
_id = 0
for course, program_status in data:
programs[unicode(course)] = [{
'id': _id,
'category': self.category,
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-1',
'status': program_status,
'course_codes': [
{
'display_name': 'Demo XSeries Program 1',
'key': unicode(course),
'run_modes': [{'sku': '', 'mode_slug': 'ABC', 'course_key': unicode(course)}]
},
{
'display_name': 'Demo XSeries Program 2',
'key': 'edx/demo/course_2',
'run_modes': [{'sku': '', 'mode_slug': 'ABC', 'course_key': 'edx/demo/course_2'}]
},
{
'display_name': 'Demo XSeries Program 3',
'key': 'edx/demo/course_3',
'run_modes': [{'sku': '', 'mode_slug': 'ABC', 'course_key': 'edx/demo/course_3'}]
}
],
'subtitle': 'sub',
'name': self.program_name
}]
_id += 1
return programs
@ddt.data(
('active', [{'sku': ''}, {'sku': ''}, {'sku': ''}, {'sku': ''}], 'marketing-slug-1'),
('active', [{'sku': ''}, {'sku': ''}, {'sku': ''}], 'marketing-slug-2'),
('active', [], ''),
('unpublished', [{'sku': ''}, {'sku': ''}, {'sku': ''}, {'sku': ''}], 'marketing-slug-3'),
)
@ddt.unpack
def test_get_xseries_programs_method(self, program_status, course_codes, marketing_slug):
"""Verify that program data is parsed correctly for a given course"""
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = {
u'edx/demox/Run_1': [{
'id': 0,
'category': self.category,
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': marketing_slug,
'status': program_status,
'course_codes': course_codes,
'subtitle': 'sub',
'name': self.program_name
}]
}
parse_data = _get_course_programs(
self.user, [
u'edx/demox/Run_1', u'valid/edX/Course'
]
)
if program_status == 'unpublished':
self.assertEqual({}, parse_data)
else:
self.assertEqual(
{
u'edx/demox/Run_1': {
'category': self.category,
'course_program_list': [{
'program_id': 0,
'course_count': len(course_codes),
'display_name': self.program_name,
'program_marketing_url': urljoin(
settings.MKTG_URLS.get('ROOT'), 'xseries' + '/{}'
).format(marketing_slug)
}]
}
},
parse_data
)
def test_program_courses_on_dashboard_without_configuration(self):
"""If programs configuration is disabled then the xseries upsell messages
will not appear on student dashboard.
"""
CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test")
with patch('student.views.get_programs_for_dashboard') as mock_method:
mock_method.return_value = self._create_program_data([])
response = self.client.get(reverse('dashboard'))
self.assertEquals(response.status_code, 200)
self.assertIn('Pursue a Certificate of Achievement to highlight', response.content)
self._assert_responses(response, 0)
@ddt.data('verified', 'honor')
def test_modes_program_courses_on_dashboard_with_configuration(self, course_mode):
"""Test that if program configuration is enabled than student can only
see those courses with xseries upsell messages which are active in
xseries programs.
"""
CourseEnrollment.enroll(self.user, self.course_1.id, mode=course_mode)
CourseEnrollment.enroll(self.user, self.course_2.id, mode=course_mode)
self.client.login(username="jack", password="test") url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
self.create_programs_config() body = json.dumps({'results': data})
with patch('student.views.get_programs_for_dashboard') as mock_data: httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json')
mock_data.return_value = self._create_program_data(
[(self.course_1.id, 'active'), (self.course_2.id, 'unpublished')]
)
response = self.client.get(reverse('dashboard'))
# count total courses appearing on student dashboard
self.assertContains(response, 'course-container', 2)
self._assert_responses(response, 1)
# for verified enrollment view the program detail button will have
# the class 'base-btn'
# for other modes view the program detail button will have have the
# class border-btn
if course_mode == 'verified':
self.assertIn('xseries-base-btn', response.content)
else:
self.assertIn('xseries-border-btn', response.content)
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
@ddt.data((-2, -1), (-1, 1), (1, 2))
@ddt.unpack
def test_start_end_offsets(self, start_days_offset, end_days_offset):
"""Test that the xseries upsell messaging displays whether the course
has not yet started, is in session, or has already ended.
"""
self.course_1.start = datetime.now(pytz.UTC) + timedelta(days=start_days_offset)
self.course_1.end = datetime.now(pytz.UTC) + timedelta(days=end_days_offset)
self.update_course(self.course_1, self.user.id)
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
self.client.login(username="jack", password="test") def assert_related_programs(self, response, are_programs_present=True):
self.create_programs_config() """Assertion for verifying response contents."""
assertion = getattr(self, 'assert{}Contains'.format('' if are_programs_present else 'Not'))
with patch( for program in self.programs:
'student.views.get_programs_for_dashboard', assertion(response, self.expected_link_text(program))
return_value=self._create_program_data([(self.course_1.id, 'active')])
) as mock_get_programs:
response = self.client.get(reverse('dashboard'))
# ensure that our course id was included in the API call regardless of start/end dates
__, course_ids = mock_get_programs.call_args[0]
self.assertEqual(list(course_ids), [self.course_1.id])
# count total courses appearing on student dashboard
self._assert_responses(response, 1)
@ddt.data(
('unpublished', 'unpublished', 'unpublished', 0),
('active', 'unpublished', 'unpublished', 1),
('active', 'active', 'unpublished', 2),
('active', 'active', 'active', 3),
)
@ddt.unpack
def test_different_programs_on_dashboard(self, status_1, status_2, status_3, program_count):
"""Test the upsell on student dashboard with different programs
statuses.
"""
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified') assertion(response, self.related_programs_preface)
CourseEnrollment.enroll(self.user, self.course_2.id, mode='honor')
CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor')
self.client.login(username="jack", password="test") def expected_link_text(self, program):
self.create_programs_config() """Construct expected dashboard link text."""
return '{name} {category}'.format(name=program['name'], category=program['category'])
with patch('student.views.get_programs_for_dashboard') as mock_data: def test_related_programs_listed(self):
mock_data.return_value = self._create_program_data( """Verify that related programs are listed when the programs API returns data."""
[(self.course_1.id, status_1), self.mock_programs_api(self.programs)
(self.course_2.id, status_2),
(self.course_3.id, status_3)]
)
response = self.client.get(reverse('dashboard')) response = self.client.get(self.url)
# count total courses appearing on student dashboard self.assert_related_programs(response)
self.assertContains(response, 'course-container', 3)
self._assert_responses(response, program_count)
@patch('student.views.log.warning') def test_no_data_no_programs(self):
@ddt.data('', 'course_codes', 'marketing_slug', 'name') """Verify that related programs aren't listed if the programs API returns no data."""
def test_program_courses_with_invalid_data(self, key_remove, log_warn): self.mock_programs_api([])
"""Test programs with invalid responses."""
CourseEnrollment.enroll(self.user, self.course_1.id) response = self.client.get(self.url)
self.client.login(username="jack", password="test") self.assert_related_programs(response, are_programs_present=False)
self.create_programs_config()
program_data = self._create_program_data([(self.course_1.id, 'active')]) def test_unrelated_program_not_listed(self):
for program in program_data[unicode(self.course_1.id)]: """Verify that unrelated programs don't appear in the listing."""
if key_remove and key_remove in program: run_mode = programs_factories.RunMode(course_key='some/nonexistent/run')
del program[key_remove] course_code = programs_factories.CourseCode(run_modes=[run_mode])
with patch('student.views.get_programs_for_dashboard') as mock_data: unrelated_program = programs_factories.Program(
mock_data.return_value = program_data organizations=[self.organization],
course_codes=[course_code]
response = self.client.get(reverse('dashboard'))
# if data is invalid then warning log will be recorded.
if key_remove:
log_warn.assert_called_with(
'Program structure is invalid, skipping display: %r', program_data[
unicode(self.course_1.id)
][0]
)
# verify that no programs related upsell messages appear on the
# student dashboard.
self._assert_responses(response, 0)
else:
# in case of valid data all upsell messages will appear on dashboard.
self._assert_responses(response, 1)
# verify that only normal courses (non-programs courses) appear on
# the student dashboard.
self.assertContains(response, 'course-container', 1)
self.assertIn('Pursue a Certificate of Achievement to highlight', response.content)
def _assert_responses(self, response, count):
"""Dry method to compare different programs related upsell messages,
classes.
"""
self.assertContains(response, 'label-xseries-association', count)
self.assertContains(response, 'btn xseries-', 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.mock_programs_api(self.programs + [unrelated_program])
self.assertContains(response, self.program_name, count * 2)
response = self.client.get(self.url)
self.assert_related_programs(response)
self.assertNotContains(response, unrelated_program['name'])
class UserAttributeTests(TestCase): class UserAttributeTests(TestCase):
......
...@@ -120,8 +120,8 @@ from notification_prefs.views import enable_notifications ...@@ -120,8 +120,8 @@ 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
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs import utils as programs_utils
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
...@@ -609,10 +609,11 @@ def dashboard(request): ...@@ -609,10 +609,11 @@ def dashboard(request):
and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview) and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
) )
# Get any programs associated with courses being displayed. # Find programs associated with courses being displayed. This information
# This is passed along in the template context to allow rendering of # is passed in the template context to allow rendering of program-related
# program-related information on the dashboard. # information on the dashboard.
course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments]) meter = programs_utils.ProgramProgressMeter(user, enrollments=course_enrollments)
programs_by_run = meter.engaged_programs(by_run=True)
# Construct a dictionary of course mode information # Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict # used to render the course list. We re-use the course modes dict
...@@ -736,9 +737,9 @@ def dashboard(request): ...@@ -736,9 +737,9 @@ def dashboard(request):
'order_history_list': order_history_list, 'order_history_list': order_history_list,
'courses_requirements_not_met': courses_requirements_not_met, 'courses_requirements_not_met': courses_requirements_not_met,
'nav_hidden': True, 'nav_hidden': True,
'course_programs': course_programs, 'programs_by_run': programs_by_run,
'disable_courseware_js': True,
'show_program_listing': ProgramsApiConfig.current().show_program_listing, 'show_program_listing': ProgramsApiConfig.current().show_program_listing,
'disable_courseware_js': True,
} }
ecommerce_service = EcommerceService() ecommerce_service = EcommerceService()
...@@ -2478,44 +2479,6 @@ def change_email_settings(request): ...@@ -2478,44 +2479,6 @@ def change_email_settings(request):
return JsonResponse({"success": True}) return JsonResponse({"success": True})
def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invalid-name
"""Build a dictionary of program data required for display on the student dashboard.
Given a user and an iterable of course keys, find all programs relevant to the
user and return them in a dictionary keyed by course key.
Arguments:
user (User): The user to authenticate as when requesting programs.
user_enrolled_courses (list): List of course keys representing the courses in which
the given user has active enrollments.
Returns:
dict, containing programs keyed by course.
"""
course_programs = get_programs_for_dashboard(user, user_enrolled_courses)
programs_data = {}
for course_key, programs in course_programs.viewitems():
for program in programs:
if program.get('status') == 'active' and program.get('category') == 'XSeries':
try:
programs_for_course = programs_data.setdefault(course_key, {})
programs_for_course.setdefault('course_program_list', []).append({
'course_count': len(program['course_codes']),
'display_name': program['name'],
'program_id': program['id'],
'program_marketing_url': urljoin(
settings.MKTG_URLS.get('ROOT'),
'xseries' + '/{}'
).format(program['marketing_slug'])
})
programs_for_course['category'] = program.get('category')
except KeyError:
log.warning('Program structure is invalid, skipping display: %r', program)
return programs_data
class LogoutView(TemplateView): class LogoutView(TemplateView):
""" """
Logs out user and redirects. Logs out user and redirects.
......
"""Learner dashboard views""" """Learner dashboard views"""
from urlparse import urljoin
from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404 from django.http import Http404
...@@ -23,19 +20,13 @@ def program_listing(request): ...@@ -23,19 +20,13 @@ def program_listing(request):
raise Http404 raise Http404
meter = utils.ProgramProgressMeter(request.user) meter = utils.ProgramProgressMeter(request.user)
programs = meter.engaged_programs
marketing_url = urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/')
for program in programs:
program['detail_url'] = utils.get_program_detail_url(program, marketing_url)
context = { context = {
'credentials': get_programs_credentials(request.user), 'credentials': get_programs_credentials(request.user),
'disable_courseware_js': True, 'disable_courseware_js': True,
'marketing_url': marketing_url, 'marketing_url': utils.get_program_marketing_url(programs_config),
'nav_hidden': True, 'nav_hidden': True,
'programs': programs, 'programs': meter.engaged_programs(),
'progress': meter.progress, 'progress': meter.progress,
'show_program_listing': programs_config.show_program_listing, 'show_program_listing': programs_config.show_program_listing,
'uses_pattern_library': True, 'uses_pattern_library': True,
......
...@@ -20,17 +20,6 @@ var edx = edx || {}; ...@@ -20,17 +20,6 @@ var edx = edx || {};
return properties; return properties;
}; };
// Generate object to be passed with programs events
edx.dashboard.generateProgramProperties = function(element) {
var $el = $(element);
return {
category: 'dashboard',
course_id: $el.closest('.course-container').find('.info-course-id').html(),
program_id: $el.data('program-id')
};
};
// Emit an event when the 'course title link' is clicked. // Emit an event when the 'course title link' is clicked.
edx.dashboard.trackCourseTitleClicked = function($courseTitleLink, properties) { edx.dashboard.trackCourseTitleClicked = function($courseTitleLink, properties) {
var trackProperty = properties || edx.dashboard.generateTrackProperties; var trackProperty = properties || edx.dashboard.generateTrackProperties;
...@@ -92,24 +81,6 @@ var edx = edx || {}; ...@@ -92,24 +81,6 @@ var edx = edx || {};
); );
}; };
// Emit an event when the 'View XSeries Details' button is clicked
edx.dashboard.trackXseriesBtnClicked = function($xseriesBtn, properties) {
var trackProperty = properties || edx.dashboard.generateProgramProperties;
window.analytics.trackLink(
$xseriesBtn,
'edx.bi.dashboard.xseries_cta_message.clicked',
trackProperty
);
};
edx.dashboard.xseriesTrackMessages = function() {
$('.xseries-action .btn').each(function(i, element) {
var data = edx.dashboard.generateProgramProperties($(element));
window.analytics.track('edx.bi.dashboard.xseries_cta_message.viewed', data);
});
};
$(document).ready(function() { $(document).ready(function() {
if (!window.analytics) { if (!window.analytics) {
return; return;
...@@ -120,7 +91,5 @@ var edx = edx || {}; ...@@ -120,7 +91,5 @@ var edx = edx || {};
edx.dashboard.trackCourseOptionDropdownClicked($('.wrapper-action-more')); edx.dashboard.trackCourseOptionDropdownClicked($('.wrapper-action-more'));
edx.dashboard.trackLearnVerifiedLinkClicked($('.verified-info')); edx.dashboard.trackLearnVerifiedLinkClicked($('.verified-info'));
edx.dashboard.trackFindCourseBtnClicked($('.btn-find-courses')); edx.dashboard.trackFindCourseBtnClicked($('.btn-find-courses'));
edx.dashboard.trackXseriesBtnClicked($('.xseries-action .btn'));
edx.dashboard.xseriesTrackMessages();
}); });
})(jQuery); })(jQuery);
...@@ -27,10 +27,6 @@ ...@@ -27,10 +27,6 @@
<div class="course-container"> <div class="course-container">
<div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">XSeries Program Course</p>
</div>
<div class="course honor"> <div class="course honor">
<section class="details" aria-labelledby="course-details-heading"> <section class="details" aria-labelledby="course-details-heading">
<h2 class="hd hd-2 sr" id="course-details-heading">Course details</h2> <h2 class="hd hd-2 sr" id="course-details-heading">Course details</h2>
...@@ -96,32 +92,11 @@ ...@@ -96,32 +92,11 @@
</div> </div>
</div> </div>
</div> </div>
<div class="message message-status is-shown credit-message">
<div class="xseries-action">
<div class="message-copy xseries-msg">
<p><b class="message-copy-bold">XSeries Program: Interested in more courses in this subject?</b></p>
<p></p>
<p class="message-copy">
This course is 1 of 3 courses in the <a href="https://www.edx.org/xseries/water-management">Water Management</a> XSeries.
</p>
</div>
<a class="btn xseries-border-btn" href="https://www.edx.org/xseries/water-management" target="_blank"
data-program-id="xseries007">
<span class="action-xseries-icon" aria-hidden="true"></span>
<span>View XSeries Details</span>
</a>
</div>
</div>
</ul> </ul>
</footer> </footer>
</div> </div>
</div> </div>
<div class="course-container"> <div class="course-container">
<div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">XSeries Program Course</p>
</div>
<div class="course honor"> <div class="course honor">
<div class="details"> <div class="details">
<div class="wrapper-course-image" aria-hidden="true"> <div class="wrapper-course-image" aria-hidden="true">
...@@ -186,22 +161,6 @@ ...@@ -186,22 +161,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="message message-status is-shown credit-message">
<div class="xseries-action">
<div class="message-copy xseries-msg">
<p><b class="message-copy-bold">XSeries Program: Interested in more courses in this subject?</b></p>
<p class="message-copy">
This course is 1 of 3 courses in the <a href="https://www.edx.org/xseries/water-management">Water Management</a> XSeries.
</p>
</div>
<a class="btn xseries-border-btn" href="https://www.edx.org/xseries/water-management" target="_blank"
data-program-id="xseries007">
<span class="action-xseries-icon" aria-hidden="true"></span>
<span>View XSeries Details</span>
</a>
</div>
</div>
</ul> </ul>
</footer> </footer>
</div> </div>
......
...@@ -92,31 +92,6 @@ ...@@ -92,31 +92,6 @@
property property
); );
}); });
it('sends an analytics event when the user clicks the \'View XSeries Details\' button', function() {
var $xseries = $('.xseries-action .btn');
window.edx.dashboard.trackXseriesBtnClicked(
$xseries,
window.edx.dashboard.generateProgramProperties);
expect(window.analytics.trackLink).toHaveBeenCalledWith(
$xseries,
'edx.bi.dashboard.xseries_cta_message.clicked',
window.edx.dashboard.generateProgramProperties
);
});
it('sends an analytics event when xseries messages are present in the DOM on page load', function() {
window.edx.dashboard.xseriesTrackMessages();
expect(window.analytics.track).toHaveBeenCalledWith(
'edx.bi.dashboard.xseries_cta_message.viewed',
{
category: 'dashboard',
course_id: 'CTB3365DWx',
program_id: 'xseries007'
}
);
});
}); });
}); });
}).call(this, window.define); }).call(this, window.define);
...@@ -3,8 +3,8 @@ ...@@ -3,8 +3,8 @@
// Uses the Pattern Library // Uses the Pattern Library
@import 'elements/banners'; @import 'elements/banners';
@import 'elements/program-card';
@import 'elements/course-card'; @import 'elements/course-card';
@import 'elements/icons'; @import 'elements/program-card';
@import 'views/program-list'; @import 'elements-v2/icons';
@import 'views/program-details'; @import 'views/program-details';
@import 'views/program-list';
...@@ -13,11 +13,12 @@ ...@@ -13,11 +13,12 @@
@import 'base/base'; @import 'base/base';
// base - elements // base - elements
@import 'elements/typography';
@import 'elements/controls'; @import 'elements/controls';
@import 'elements/creative-commons';
@import 'elements/icons';
@import 'elements/navigation'; @import 'elements/navigation';
@import 'elements/pagination'; @import 'elements/pagination';
@import 'elements/creative-commons'; @import 'elements/typography';
// shared - course // shared - course
@import 'shared/fields'; @import 'shared/fields';
......
.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;
}
.certificate-body {
// Use the ampersand to reference parent selectors.
.certificate-icon & {
@include float(left);
@include margin-right($baseline*0.4);
margin-top: ($baseline/10);
width: 23px;
height: 20px;
padding: 2px;
background-color: $white;
border-style: solid;
border-width: 2px;
}
.green-icon & {
fill: palette(success, text);
border-color: palette(success, text);
}
.blue-icon & {
fill: palette(primary, dark);
border-color: palette(primary, dark);
}
}
.certificate-icon .certificate-body { .xseries-icon {
@include float(left); background: url('#{$static-path}/images/programs/xseries-icon.svg') no-repeat;
@include margin-right($baseline*0.4);
margin-top: ($baseline/10);
width: 23px;
height: 20px;
padding: 2px;
background-color: $white;
border-style: solid;
border-width: 2px;
}
.green-certificate-icon .certificate-body {
fill: palette(success, accent);
border-color: palette(success, accent);
} }
.blue-certificate-icon .certificate-body { .micromasters-icon {
fill: palette(primary, dark); margin-top: $baseline * 0.05;
border-color: palette(primary, dark); background: url('#{$static-path}/images/programs/micromasters-icon.svg') no-repeat;
} }
...@@ -97,15 +97,6 @@ ...@@ -97,15 +97,6 @@
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 {
......
...@@ -62,32 +62,6 @@ ...@@ -62,32 +62,6 @@
} }
} }
.wrapper-xseries-certificates{
@include float(right);
@include margin-left(flex-gutter());
width: flex-grid(3);
.title{
@extend %t-title7;
@extend %t-weight4;
}
ul{
@include padding-left(0);
margin-top: ($baseline/2);
}
li{
@include line-height(20);
list-style-type: none;
}
.copy {
@extend %t-copy-sub1;
margin-top: ($baseline/2);
}
}
.profile-sidebar { .profile-sidebar {
background: transparent; background: transparent;
@include float(right); @include float(right);
...@@ -304,31 +278,11 @@ ...@@ -304,31 +278,11 @@
border-bottom: 4px solid $border-color-l4; border-bottom: 4px solid $border-color-l4;
padding-bottom: $baseline; padding-bottom: $baseline;
.course-container{ .course-container {
border: 1px solid $border-color-l4; border: 1px solid $border-color-l4;
border-radius: 3px; border-radius: 3px;
// CASE: Xseries associated course
.label-xseries-association{
@include margin($baseline/2, $baseline/5, 0, $baseline/2);
.xseries-icon{
@include float(left);
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
.message-copy{
padding-top: ($baseline/5);
@extend %t-action3;
}
}
} }
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
border-bottom: none; border-bottom: none;
...@@ -860,100 +814,6 @@ ...@@ -860,100 +814,6 @@
} }
} }
.xseries-action{
.xseries-msg{
@include float(left);
width: flex-grid(9, 12);
}
.message-copy{
@extend %t-demi-strong;
margin-top: 0;
}
.message-copy-bold{
@extend %t-strong;
}
.xseries-border-btn {
@extend %btn-pl-black-border;
@include float(right);
position: relative;
@include left(10px);
padding: ($baseline*0.4) ($baseline*0.6);
background-image: none ;
text-shadow: none;
box-shadow: none;
text-transform: none;
.action-xseries-icon{
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
&:hover,
&:focus {
.action-xseries-icon{
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-white.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
}
}
.xseries-base-btn {
@extend %btn-pl-black-base;
@include float(right);
position: relative;
@include left(10px);
padding: ($baseline*0.4) ($baseline*0.6);
background-image: none ;
text-shadow: none;
box-shadow: none;
text-transform: none;
.action-xseries-icon{
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-white.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
&:hover,
&:focus {
.action-xseries-icon {
@include float(left);
display: inline;
@include margin-right($baseline*0.4);
background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat;
background-color: transparent;
width: ($baseline*1.1);
height: ($baseline*1.1);
}
}
}
}
.actions { .actions {
.action { .action {
...@@ -1129,6 +989,46 @@ ...@@ -1129,6 +989,46 @@
} }
} }
&.message-related-programs {
background: none;
border: none;
margin-top: ($baseline/4);
padding-bottom: 0;
.related-programs-preface {
@include float(left);
font-weight: bold;
}
ul {
display: inline;
padding: 0;
margin: 0;
}
li {
@include float(left);
display: inline;
padding: 0 0.5em;
border-right: 1px solid;
.category-icon {
@include float(left);
@include margin-right($baseline/4);
margin-top: ($baseline/10);
background-color: transparent;
background-size: 100%;
width: ($baseline*0.7);
height: ($baseline*0.7);
}
}
// Remove separator from last list item.
li:last-child {
border: 0;
}
}
// TYPE: pre-requisites // TYPE: pre-requisites
.prerequisites { .prerequisites {
@include clearfix; @include clearfix;
......
...@@ -98,8 +98,8 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -98,8 +98,8 @@ from openedx.core.djangolib.markup import HTML, Text
<% is_course_blocked = (enrollment.course_id in block_courses) %> <% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.get(unicode(enrollment.course_id)) %> <% related_programs = programs_by_run.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" /> <%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs" />
% endfor % endfor
</ul> </ul>
......
<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, course_program_info" expression_filter="h"/> <%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs" expression_filter="h"/>
<%! <%!
import urllib import urllib
...@@ -53,12 +53,6 @@ from student.helpers import ( ...@@ -53,12 +53,6 @@ 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':
<div class="label-xseries-association">
<span class="xseries-icon" aria-hidden="true"></span>
<p class="message-copy">${_("{category} Program Course").format(category=course_program_info['category'])}</p>
</div>
% endif
<article class="course${mode_class}"> <article class="course${mode_class}">
<% course_target = reverse('info', args=[unicode(course_overview.id)]) %> <% course_target = reverse('info', args=[unicode(course_overview.id)]) %>
<section class="details" aria-labelledby="details-heading-${course_overview.number}"> <section class="details" aria-labelledby="details-heading-${course_overview.number}">
...@@ -283,6 +277,20 @@ from student.helpers import ( ...@@ -283,6 +277,20 @@ from student.helpers import (
</section> </section>
<footer class="wrapper-messages-primary"> <footer class="wrapper-messages-primary">
<ul class="messages-list"> <ul class="messages-list">
% if related_programs:
<div class="message message-related-programs is-shown">
<span class="related-programs-preface">${_('Related Programs')}:</span>
<ul>
% for program in related_programs:
<li>
<span class="category-icon ${program['category'].lower()}-icon" aria-hidden="true"></span>
<span><a href="${program['detail_url']}">${'{name} {category}'.format(name=program['name'], category=program['category'])}</a></span>
</li>
% endfor
</ul>
</div>
% endif
% if course_overview.may_certify() and cert_status: % if course_overview.may_certify() and cert_status:
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course_overview=course_overview, enrollment=enrollment, reverify_link=reverify_link'/> <%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course_overview=course_overview, enrollment=enrollment, reverify_link=reverify_link'/>
% endif % endif
......
<%page expression_filter="h" args="program_data, enrollment_mode, category" />
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
%>
<%namespace name='static' file='../static_content.html'/>
<div class="message message-status is-shown credit-message">
<div class="xseries-action">
<div class="message-copy xseries-msg">
<p class="message-copy-bold">
${_("{category} Program: Interested in more courses in this subject?").format(category=category)}
</p>
<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(
course_count=program_data['course_count'],
link_start=HTML('<a href="{}">').format(program_data['program_marketing_url']),
link_end=HTML('</a>'),
program_display_name=program_data['display_name'],
program_category=category,
)}
</p>
</div>
<%
xseries_btn_class = "xseries-border-btn"
if enrollment_mode == "verified":
xseries_btn_class = "xseries-base-btn";
%>
<a class="btn ${xseries_btn_class}" href="${program_data['program_marketing_url']}" target="_blank"
data-program-id="${program_data['program_id']}" >
<span class="sr">${program_data['display_name']}</span>
<span class="action-xseries-icon" aria-hidden="true"></span>
${_("View {category} Details").format(category=category)}
</a>
</div>
</div>
<div class="message col-12 md-col-8"> <div class="message col-12 md-col-8">
<% // safe-lint: disable=underscore-not-escaped %> <% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon green-certificate-icon" aria-hidden="true"><%= certificateSvg %></span> <span class="certificate-icon green-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="card-msg"><%- gettext('Congratulations! You have earned a certificate for this course.') %></span> <span class="card-msg"><%- gettext('Congratulations! You have earned a certificate for this course.') %></span>
</div> </div>
<div class="action col-12 md-col-4"> <div class="action col-12 md-col-4">
<a href="<%- certificate_url %>" class="btn-brand cta-secondary"> <a href="<%- certificate_url %>" class="btn-brand cta-secondary">
<% // safe-lint: disable=underscore-not-escaped %> <% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon blue-certificate-icon" aria-hidden="true"><%= certificateSvg %></span> <span class="certificate-icon blue-icon" aria-hidden="true"><%= certificateSvg %></span>
<%- gettext('View Certificate') %> <%- gettext('View Certificate') %>
</a> </a>
</div> </div>
<div class="message col-12 md-col-8"> <div class="message col-12 md-col-8">
<% // safe-lint: disable=underscore-not-escaped %> <% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon green-certificate-icon" aria-hidden="true"><%= certificateSvg %></span> <span class="certificate-icon green-icon" aria-hidden="true"><%= certificateSvg %></span>
<span class="card-msg"><%- gettext('You need a certificate in this course to be eligible for a program certificate.') %></span> <span class="card-msg"><%- gettext('You need a certificate in this course to be eligible for a program certificate.') %></span>
</div> </div>
<div class="action col-12 md-col-4"> <div class="action col-12 md-col-4">
<a href="<%- upgrade_url %>" class="btn-brand cta-primary"> <a href="<%- upgrade_url %>" class="btn-brand cta-primary">
<% // safe-lint: disable=underscore-not-escaped %> <% // safe-lint: disable=underscore-not-escaped %>
<span class="certificate-icon green-certificate-icon" aria-hidden="true"><%= certificateSvg %></span> <span class="certificate-icon green-icon" aria-hidden="true"><%= certificateSvg %></span>
<%- gettext('Upgrade Now') %> <%- gettext('Upgrade Now') %>
</a> </a>
</div> </div>
...@@ -7,6 +7,7 @@ from django.db import models ...@@ -7,6 +7,7 @@ from django.db import models
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
# TODO: To be simplified as part of ECOM-5136.
class ProgramsApiConfig(ConfigurationModel): class ProgramsApiConfig(ConfigurationModel):
""" """
Manages configuration for connecting to the Programs service and using its Manages configuration for connecting to the Programs service and using its
...@@ -29,7 +30,6 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -29,7 +30,6 @@ class ProgramsApiConfig(ConfigurationModel):
) )
) )
# 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"),
max_length=255, max_length=255,
...@@ -39,7 +39,6 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -39,7 +39,6 @@ class ProgramsApiConfig(ConfigurationModel):
) )
) )
# TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995
authoring_app_css_path = models.CharField( authoring_app_css_path = models.CharField(
verbose_name=_("Path to authoring app's CSS"), verbose_name=_("Path to authoring app's CSS"),
max_length=255, max_length=255,
...@@ -81,7 +80,6 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -81,7 +80,6 @@ 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
...@@ -117,14 +115,6 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -117,14 +115,6 @@ class ProgramsApiConfig(ConfigurationModel):
return self.cache_ttl > 0 return self.cache_ttl > 0
@property @property
def is_student_dashboard_enabled(self):
"""
Indicates whether LMS dashboard functionality related to Programs should
be enabled or not.
"""
return self.enabled and self.enable_student_dashboard
@property
def is_studio_tab_enabled(self): def is_studio_tab_enabled(self):
""" """
Indicates whether Studio functionality related to Programs should Indicates whether Studio functionality related to Programs should
......
...@@ -14,7 +14,7 @@ class Program(factory.Factory): ...@@ -14,7 +14,7 @@ class Program(factory.Factory):
name = FuzzyText(prefix='Program ') name = FuzzyText(prefix='Program ')
subtitle = FuzzyText(prefix='Subtitle ') subtitle = FuzzyText(prefix='Subtitle ')
category = 'FooBar' category = 'FooBar'
status = 'unpublished' status = 'active'
marketing_slug = FuzzyText(prefix='slug_') marketing_slug = FuzzyText(prefix='slug_')
organizations = [] organizations = []
course_codes = [] course_codes = []
......
...@@ -16,7 +16,6 @@ class ProgramsApiConfigMixin(object): ...@@ -16,7 +16,6 @@ class ProgramsApiConfigMixin(object):
'internal_service_url': 'http://internal.programs.org/', 'internal_service_url': 'http://internal.programs.org/',
'public_service_url': 'http://public.programs.org/', 'public_service_url': 'http://public.programs.org/',
'cache_ttl': 0, 'cache_ttl': 0,
'enable_student_dashboard': True,
'enable_studio_tab': True, 'enable_studio_tab': True,
'enable_certification': True, 'enable_certification': True,
'program_listing_enabled': True, 'program_listing_enabled': True,
......
...@@ -36,20 +36,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase): ...@@ -36,20 +36,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
programs_config = self.create_programs_config(cache_ttl=cache_ttl) programs_config = self.create_programs_config(cache_ttl=cache_ttl)
self.assertEqual(programs_config.is_cache_enabled, is_cache_enabled) self.assertEqual(programs_config.is_cache_enabled, is_cache_enabled)
def test_is_student_dashboard_enabled(self, _mock_cache):
"""
Verify that the property controlling display on the student dashboard is only True
when configuration is enabled and all required configuration is provided.
"""
programs_config = self.create_programs_config(enabled=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
programs_config = self.create_programs_config(enable_student_dashboard=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
programs_config = self.create_programs_config()
self.assertTrue(programs_config.is_student_dashboard_enabled)
def test_is_studio_tab_enabled(self, _mock_cache): def test_is_studio_tab_enabled(self, _mock_cache):
""" """
Verify that the property controlling display of the Studio tab is only True Verify that the property controlling display of the Studio tab is only True
......
...@@ -12,9 +12,11 @@ from django.core.urlresolvers import reverse ...@@ -12,9 +12,11 @@ from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify
import httpretty import httpretty
import mock import mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from opaque_keys.edx.keys import CourseKey
from edx_oauth2_provider.tests.factories import ClientFactory from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL from provider.constants import CONFIDENTIAL
...@@ -141,36 +143,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential ...@@ -141,36 +143,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
actual = utils.get_programs(self.user) actual = utils.get_programs(self.user)
self.assertEqual(actual, []) self.assertEqual(actual, [])
def test_get_programs_for_dashboard(self):
"""Verify programs data can be retrieved and parsed correctly."""
self.create_programs_config()
self.mock_programs_api()
actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS)
expected = {}
for program in self.PROGRAMS_API_RESPONSE['results']:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
course_key = run['course_key']
expected.setdefault(course_key, []).append(program)
self.assertEqual(actual, expected)
def test_get_programs_for_dashboard_dashboard_display_disabled(self):
"""Verify behavior when student dashboard display is disabled."""
self.create_programs_config(enable_student_dashboard=False)
actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
def test_get_programs_for_dashboard_no_data(self):
"""Verify behavior when no programs data is found for the user."""
self.create_programs_config()
self.mock_programs_api(data={'results': []})
actual = utils.get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
def test_get_program_for_certificates(self): def test_get_program_for_certificates(self):
"""Verify programs data can be retrieved and parsed correctly for certificates.""" """Verify programs data can be retrieved and parsed correctly for certificates."""
self.create_programs_config() self.create_programs_config()
...@@ -219,6 +191,78 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential ...@@ -219,6 +191,78 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class GetProgramsByRunTests(TestCase):
"""Tests verifying that programs are inverted correctly."""
maxDiff = None
@classmethod
def setUpClass(cls):
super(GetProgramsByRunTests, cls).setUpClass()
cls.user = UserFactory()
course_keys = [
CourseKey.from_string('some/course/run'),
CourseKey.from_string('some/other/run'),
]
cls.enrollments = [CourseEnrollmentFactory(user=cls.user, course_id=c) for c in course_keys]
cls.course_ids = [unicode(c) for c in course_keys]
organization = factories.Organization()
joint_programs = sorted([
factories.Program(
organizations=[organization],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=cls.course_ids[0]),
]),
]
) for __ in range(2)
], key=lambda p: p['name'])
cls.programs = joint_programs + [
factories.Program(
organizations=[organization],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=cls.course_ids[1]),
]),
]
),
factories.Program(
organizations=[organization],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key='yet/another/run'),
]),
]
),
]
def test_get_programs_by_run(self):
"""Verify that programs are organized by run ID."""
programs_by_run, course_ids = utils.get_programs_by_run(self.programs, self.enrollments)
self.assertEqual(programs_by_run[self.course_ids[0]], self.programs[:2])
self.assertEqual(programs_by_run[self.course_ids[1]], self.programs[2:3])
self.assertEqual(course_ids, self.course_ids)
def test_no_programs(self):
"""Verify that the utility can cope with missing programs data."""
programs_by_run, course_ids = utils.get_programs_by_run([], self.enrollments)
self.assertEqual(programs_by_run, {})
self.assertEqual(course_ids, self.course_ids)
def test_no_enrollments(self):
"""Verify that the utility can cope with missing enrollment data."""
programs_by_run, course_ids = utils.get_programs_by_run(self.programs, [])
self.assertEqual(programs_by_run, {})
self.assertEqual(course_ids, [])
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class GetCompletedCoursesTestCase(TestCase): class GetCompletedCoursesTestCase(TestCase):
""" """
Test the get_completed_courses function Test the get_completed_courses function
...@@ -297,6 +341,14 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -297,6 +341,14 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
"""Construct a list containing the display names of the indicated course codes.""" """Construct a list containing the display names of the indicated course codes."""
return [program['course_codes'][cc]['display_name'] for cc in course_codes] return [program['course_codes'][cc]['display_name'] for cc in course_codes]
def _attach_detail_url(self, programs):
"""Add expected detail URLs to a list of program dicts."""
for program in programs:
base = reverse('program_details_view', kwargs={'program_id': program['id']}).rstrip('/')
slug = slugify(program['name'])
program['detail_url'] = '{base}/{slug}'.format(base=base, slug=slug)
def test_no_enrollments(self): def test_no_enrollments(self):
"""Verify behavior when programs exist, but no relevant enrollments do.""" """Verify behavior when programs exist, but no relevant enrollments do."""
data = [ data = [
...@@ -311,7 +363,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -311,7 +363,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
meter = utils.ProgramProgressMeter(self.user) meter = utils.ProgramProgressMeter(self.user)
self.assertEqual(meter.engaged_programs, []) self.assertEqual(meter.engaged_programs(), [])
self._assert_progress(meter) self._assert_progress(meter)
self.assertEqual(meter.completed_programs, []) self.assertEqual(meter.completed_programs, [])
...@@ -322,7 +374,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -322,7 +374,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments('org/course/run') self._create_enrollments('org/course/run')
meter = utils.ProgramProgressMeter(self.user) meter = utils.ProgramProgressMeter(self.user)
self.assertEqual(meter.engaged_programs, []) self.assertEqual(meter.engaged_programs(), [])
self._assert_progress(meter) self._assert_progress(meter)
self.assertEqual(meter.completed_programs, []) self.assertEqual(meter.completed_programs, [])
...@@ -353,8 +405,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -353,8 +405,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments(course_id) self._create_enrollments(course_id)
meter = utils.ProgramProgressMeter(self.user) meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
program = data[0] program = data[0]
self.assertEqual(meter.engaged_programs, [program]) self.assertEqual(meter.engaged_programs(), [program])
self._assert_progress( self._assert_progress(
meter, meter,
factories.Progress( factories.Progress(
...@@ -399,8 +452,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -399,8 +452,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments(second_course_id, first_course_id) self._create_enrollments(second_course_id, first_course_id)
meter = utils.ProgramProgressMeter(self.user) meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
programs = data[:2] programs = data[:2]
self.assertEqual(meter.engaged_programs, programs) self.assertEqual(meter.engaged_programs(), programs)
self._assert_progress( self._assert_progress(
meter, meter,
factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)), factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)),
...@@ -414,15 +468,8 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -414,15 +468,8 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
appearing in multiple programs. appearing in multiple programs.
""" """
shared_course_id, solo_course_id = 'org/shared-course/run', 'org/solo-course/run' shared_course_id, solo_course_id = 'org/shared-course/run', 'org/solo-course/run'
data = [
factories.Program( joint_programs = sorted([
organizations=[factories.Organization()],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=shared_course_id),
]),
]
),
factories.Program( factories.Program(
organizations=[factories.Organization()], organizations=[factories.Organization()],
course_codes=[ course_codes=[
...@@ -430,7 +477,10 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -430,7 +477,10 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
factories.RunMode(course_key=shared_course_id), factories.RunMode(course_key=shared_course_id),
]), ]),
] ]
), ) for __ in range(2)
], key=lambda p: p['name'])
data = joint_programs + [
factories.Program( factories.Program(
organizations=[factories.Organization()], organizations=[factories.Organization()],
course_codes=[ course_codes=[
...@@ -446,14 +496,16 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): ...@@ -446,14 +496,16 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
] ]
), ),
] ]
self._mock_programs_api(data) self._mock_programs_api(data)
# Enrollment for the shared course ID created last (most recently). # Enrollment for the shared course ID created last (most recently).
self._create_enrollments(solo_course_id, shared_course_id) self._create_enrollments(solo_course_id, shared_course_id)
meter = utils.ProgramProgressMeter(self.user) meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
programs = data[:3] programs = data[:3]
self.assertEqual(meter.engaged_programs, programs) self.assertEqual(meter.engaged_programs(), programs)
self._assert_progress( self._assert_progress(
meter, meter,
factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)), factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)),
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
"""Helper functions for working with Programs.""" """Helper functions for working with Programs."""
import datetime import datetime
import logging import logging
from urlparse import urljoin
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify from django.utils.text import slugify
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
import pytz import pytz
...@@ -52,63 +53,6 @@ def get_programs(user, program_id=None): ...@@ -52,63 +53,6 @@ def get_programs(user, program_id=None):
return 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)
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.
Given a user and an iterable of course keys, find all the programs relevant
to the user's dashboard and return them in a dictionary keyed by course key.
Arguments:
user (User): The user to authenticate as when requesting programs.
course_keys (list): List of course keys representing the courses in which
the given user has active enrollments.
Returns:
dict, containing programs keyed by course. Empty if programs cannot be retrieved.
"""
programs_config = ProgramsApiConfig.current()
course_programs = {}
if not programs_config.is_student_dashboard_enabled:
log.debug('Display of programs on the student dashboard is disabled.')
return course_programs
programs = get_programs(user)
if not programs:
log.debug('No programs found for the user with ID %d.', user.id)
return course_programs
course_ids = [unicode(c) for c in course_keys]
course_programs = flatten_programs(programs, course_ids)
return course_programs
def get_programs_for_credentials(user, programs_credentials): def get_programs_for_credentials(user, programs_credentials):
""" Given a user and an iterable of credentials, get corresponding programs """ Given a user and an iterable of credentials, get corresponding programs
data and return it as a list of dictionaries. data and return it as a list of dictionaries.
...@@ -137,24 +81,71 @@ def get_programs_for_credentials(user, programs_credentials): ...@@ -137,24 +81,71 @@ def get_programs_for_credentials(user, programs_credentials):
return certificate_programs return certificate_programs
def get_program_detail_url(program, marketing_root): def get_programs_by_run(programs, enrollments):
"""Construct the URL to be used when linking to program details. """Intersect programs and enrollments.
Builds a dictionary of program dict lists keyed by course ID. The resulting dictionary
is suitable for use in applications where programs must be filtered by the course
runs they contain (e.g., student dashboard).
Arguments: Arguments:
program (dict): Representation of a program. programs (list): Containing dictionaries representing programs.
marketing_root (str): Root URL used to build links to program marketing pages. enrollments (list): Enrollments from which course IDs to key on can be extracted.
Returns: Returns:
str, a link to program details tuple, dict of programs keyed by course ID and list of course IDs themselves
""" """
if ProgramsApiConfig.current().show_program_details: programs_by_run = {}
base = reverse('program_details_view', kwargs={'program_id': program['id']}).rstrip('/') # enrollment.course_id is really a course key (╯ಠ_ಠ)╯︵ ┻━┻
slug = slugify(program['name']) course_ids = [unicode(e.course_id) for e in enrollments]
else:
base = marketing_root.rstrip('/') for program in programs:
slug = program['marketing_slug'] 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:
program_list = programs_by_run.setdefault(run_id, list())
if program not in program_list:
program_list.append(program)
# Sort programs by name for consistent presentation.
for program_list in programs_by_run.itervalues():
program_list.sort(key=lambda p: p['name'])
return programs_by_run, course_ids
return '{base}/{slug}'.format(base=base, slug=slug) def get_program_marketing_url(programs_config):
"""Build a URL to be used when linking to program details on a marketing site."""
return urljoin(settings.MKTG_URLS.get('ROOT'), programs_config.marketing_path).rstrip('/')
def attach_program_detail_url(programs):
"""Extend program representations by attaching a URL to be used when linking to program details.
Facilitates the building of context to be passed to templates containing program data.
Arguments:
programs (list): Containing dicts representing programs.
Returns:
list, containing extended program dicts
"""
programs_config = ProgramsApiConfig.current()
marketing_url = get_program_marketing_url(programs_config)
for program in programs:
if programs_config.show_program_details:
base = reverse('program_details_view', kwargs={'program_id': program['id']}).rstrip('/')
slug = slugify(program['name'])
else:
# TODO: Remove. Learners should always be sent to the LMS' program details page.
base = marketing_url
slug = program['marketing_slug']
program['detail_url'] = '{base}/{slug}'.format(base=base, slug=slug)
return programs
def get_completed_courses(student): def get_completed_courses(student):
...@@ -182,35 +173,40 @@ class ProgramProgressMeter(object): ...@@ -182,35 +173,40 @@ class ProgramProgressMeter(object):
Arguments: Arguments:
user (User): The user for which to find programs. user (User): The user for which to find programs.
Keyword Arguments:
enrollments (list): List of the user's enrollments.
""" """
def __init__(self, user): def __init__(self, user, enrollments=None):
self.user = user self.user = user
self.enrollments = enrollments
self.course_ids = None self.course_ids = None
self.course_certs = None
self.programs = get_programs(self.user) self.programs = attach_program_detail_url(get_programs(self.user))
self.course_certs = get_completed_courses(self.user)
@cached_property def engaged_programs(self, by_run=False):
def engaged_programs(self):
"""Derive a list of programs in which the given user is engaged. """Derive a list of programs in which the given user is engaged.
Returns: Returns:
list of program dicts, ordered by most recent enrollment. list of program dicts, ordered by most recent enrollment,
or dict of programs, keyed by course ID.
""" """
enrollments = CourseEnrollment.enrollments_for_user(self.user) self.enrollments = self.enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
enrollments = sorted(enrollments, key=lambda e: e.created, reverse=True) self.enrollments.sort(key=lambda e: e.created, reverse=True)
# enrollment.course_id is really a course key ಠ_ಠ
self.course_ids = [unicode(e.course_id) for e in enrollments] programs_by_run, self.course_ids = get_programs_by_run(self.programs, self.enrollments)
flattened = flatten_programs(self.programs, self.course_ids) if by_run:
return programs_by_run
engaged_programs = [] programs = []
for course_id in self.course_ids: for course_id in self.course_ids:
for program in flattened.get(course_id, []): for program in programs_by_run.get(course_id, []):
if program not in engaged_programs: if program not in programs:
engaged_programs.append(program) programs.append(program)
return engaged_programs return programs
@property @property
def progress(self): def progress(self):
...@@ -221,7 +217,7 @@ class ProgramProgressMeter(object): ...@@ -221,7 +217,7 @@ class ProgramProgressMeter(object):
towards completing a program. towards completing a program.
""" """
progress = [] progress = []
for program in self.engaged_programs: for program in self.engaged_programs():
completed, in_progress, not_started = [], [], [] completed, in_progress, not_started = [], [], []
for course_code in program['course_codes']: for course_code in program['course_codes']:
...@@ -277,6 +273,8 @@ class ProgramProgressMeter(object): ...@@ -277,6 +273,8 @@ class ProgramProgressMeter(object):
Returns: Returns:
bool, whether the course code is complete. bool, whether the course code is complete.
""" """
self.course_certs = self.course_certs or get_completed_courses(self.user)
return any(self._parse(run_mode) in self.course_certs for run_mode in course_code['run_modes']) return any(self._parse(run_mode) in self.course_certs for run_mode in course_code['run_modes'])
def _is_course_code_in_progress(self, course_code): def _is_course_code_in_progress(self, course_code):
......
...@@ -1877,6 +1877,7 @@ class TestGoogleRegistrationView( ...@@ -1877,6 +1877,7 @@ class TestGoogleRegistrationView(
@ddt.ddt @ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase): class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase):
"""Tests the UpdateEmailOptInPreference view. """ """Tests the UpdateEmailOptInPreference view. """
......
...@@ -99,8 +99,8 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers ...@@ -99,8 +99,8 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
<% is_course_blocked = (enrollment.course_id in block_courses) %> <% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.get(unicode(enrollment.course_id)) %> <% related_programs = programs_by_run.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" /> <%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs" />
% endfor % endfor
</ul> </ul>
......
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