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
class ProgramAuthoringView(View):
"""View rendering a template which hosts the Programs authoring app.
The Programs authoring app is a Backbone SPA maintained in a separate repository.
The app handles its own routing and provides a UI which can be used to create and
publish new Programs (e.g, XSeries).
The Programs authoring app is a Backbone SPA. The app handles its own routing
and provides a UI which can be used to create and publish new Programs.
"""
@method_decorator(login_required)
......
......@@ -2,53 +2,56 @@
"""
Miscellaneous tests for the student app.
"""
from datetime import datetime, timedelta
import json
import logging
import unittest
import ddt
from datetime import datetime, timedelta
from urlparse import urljoin
import pytz
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
import ddt
from django.conf import settings
from django.contrib.auth.models import User, AnonymousUser
from django.core.urlresolvers import reverse
from django.test import TestCase
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 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 (
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment,
unique_id_for_user, LinkedInAddToProfileConfiguration, UserAttribute
)
from student.tests.factories import UserFactory, CourseModeFactory, CourseEnrollmentFactory
from student.views import (
process_survey_link,
_cert_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.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.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__)
......@@ -889,276 +892,95 @@ class AnonymousLookupTable(ModuleStoreTestCase):
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)
@httpretty.activate
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
"""
Tests for dashboard for xseries program courses. Enroll student into
programs and then try different combinations to see xseries upsell
messages are appearing.
"""
class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Tests verifying that related programs appear on the course dashboard."""
url = None
maxDiff = None
password = 'test'
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):
super(DashboardTestXSeriesPrograms, self).setUp()
super(RelatedProgramsTests, self).setUp()
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
self.course_1 = CourseFactory.create()
self.course_2 = CourseFactory.create()
self.course_3 = CourseFactory.create()
self.program_name = 'Testing Program'
self.category = 'XSeries'
self.url = reverse('dashboard')
CourseModeFactory.create(
course_id=self.course_1.id,
mode_slug='verified',
mode_display_name='Verified',
expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=1)
)
self.client = Client()
cache.clear()
self.create_programs_config()
self.client.login(username=self.user.username, password=self.password)
def _create_program_data(self, data):
"""Dry method to create testing programs data."""
programs = {}
_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)
def mock_programs_api(self, data):
"""Helper for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
self.client.login(username="jack", password="test")
self.create_programs_config()
url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
body = json.dumps({'results': data})
with patch('student.views.get_programs_for_dashboard') as mock_data:
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')
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json')
self.client.login(username="jack", password="test")
self.create_programs_config()
def assert_related_programs(self, response, are_programs_present=True):
"""Assertion for verifying response contents."""
assertion = getattr(self, 'assert{}Contains'.format('' if are_programs_present else 'Not'))
with patch(
'student.views.get_programs_for_dashboard',
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.
"""
for program in self.programs:
assertion(response, self.expected_link_text(program))
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
CourseEnrollment.enroll(self.user, self.course_2.id, mode='honor')
CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor')
assertion(response, self.related_programs_preface)
self.client.login(username="jack", password="test")
self.create_programs_config()
def expected_link_text(self, program):
"""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:
mock_data.return_value = self._create_program_data(
[(self.course_1.id, status_1),
(self.course_2.id, status_2),
(self.course_3.id, status_3)]
)
def test_related_programs_listed(self):
"""Verify that related programs are listed when the programs API returns data."""
self.mock_programs_api(self.programs)
response = self.client.get(reverse('dashboard'))
# count total courses appearing on student dashboard
self.assertContains(response, 'course-container', 3)
self._assert_responses(response, program_count)
response = self.client.get(self.url)
self.assert_related_programs(response)
@patch('student.views.log.warning')
@ddt.data('', 'course_codes', 'marketing_slug', 'name')
def test_program_courses_with_invalid_data(self, key_remove, log_warn):
"""Test programs with invalid responses."""
def test_no_data_no_programs(self):
"""Verify that related programs aren't listed if the programs API returns no data."""
self.mock_programs_api([])
CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test")
self.create_programs_config()
response = self.client.get(self.url)
self.assert_related_programs(response, are_programs_present=False)
program_data = self._create_program_data([(self.course_1.id, 'active')])
for program in program_data[unicode(self.course_1.id)]:
if key_remove and key_remove in program:
del program[key_remove]
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = program_data
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
def test_unrelated_program_not_listed(self):
"""Verify that unrelated programs don't appear in the listing."""
run_mode = programs_factories.RunMode(course_key='some/nonexistent/run')
course_code = programs_factories.CourseCode(run_modes=[run_mode])
unrelated_program = programs_factories.Program(
organizations=[self.organization],
course_codes=[course_code]
)
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, self.program_name, count * 2)
self.mock_programs_api(self.programs + [unrelated_program])
response = self.client.get(self.url)
self.assert_related_programs(response)
self.assertNotContains(response, unrelated_program['name'])
class UserAttributeTests(TestCase):
......
......@@ -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.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 import utils as programs_utils
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
......@@ -609,10 +609,11 @@ def dashboard(request):
and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
)
# Get any programs associated with courses being displayed.
# This is passed along in the template context to allow rendering of
# program-related information on the dashboard.
course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments])
# Find programs associated with courses being displayed. This information
# is passed in the template context to allow rendering of program-related
# information on the dashboard.
meter = programs_utils.ProgramProgressMeter(user, enrollments=course_enrollments)
programs_by_run = meter.engaged_programs(by_run=True)
# Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict
......@@ -736,9 +737,9 @@ def dashboard(request):
'order_history_list': order_history_list,
'courses_requirements_not_met': courses_requirements_not_met,
'nav_hidden': True,
'course_programs': course_programs,
'disable_courseware_js': True,
'programs_by_run': programs_by_run,
'show_program_listing': ProgramsApiConfig.current().show_program_listing,
'disable_courseware_js': True,
}
ecommerce_service = EcommerceService()
......@@ -2478,44 +2479,6 @@ def change_email_settings(request):
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):
"""
Logs out user and redirects.
......
"""Learner dashboard views"""
from urlparse import urljoin
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http import Http404
......@@ -23,19 +20,13 @@ def program_listing(request):
raise Http404
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 = {
'credentials': get_programs_credentials(request.user),
'disable_courseware_js': True,
'marketing_url': marketing_url,
'marketing_url': utils.get_program_marketing_url(programs_config),
'nav_hidden': True,
'programs': programs,
'programs': meter.engaged_programs(),
'progress': meter.progress,
'show_program_listing': programs_config.show_program_listing,
'uses_pattern_library': True,
......
......@@ -20,17 +20,6 @@ var edx = edx || {};
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.
edx.dashboard.trackCourseTitleClicked = function($courseTitleLink, properties) {
var trackProperty = properties || edx.dashboard.generateTrackProperties;
......@@ -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() {
if (!window.analytics) {
return;
......@@ -120,7 +91,5 @@ var edx = edx || {};
edx.dashboard.trackCourseOptionDropdownClicked($('.wrapper-action-more'));
edx.dashboard.trackLearnVerifiedLinkClicked($('.verified-info'));
edx.dashboard.trackFindCourseBtnClicked($('.btn-find-courses'));
edx.dashboard.trackXseriesBtnClicked($('.xseries-action .btn'));
edx.dashboard.xseriesTrackMessages();
});
})(jQuery);
......@@ -27,10 +27,6 @@
<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">
<section class="details" aria-labelledby="course-details-heading">
<h2 class="hd hd-2 sr" id="course-details-heading">Course details</h2>
......@@ -96,32 +92,11 @@
</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>
</footer>
</div>
</div>
<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="details">
<div class="wrapper-course-image" aria-hidden="true">
......@@ -186,22 +161,6 @@
</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>
</footer>
</div>
......
......@@ -92,31 +92,6 @@
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);
......@@ -3,8 +3,8 @@
// Uses the Pattern Library
@import 'elements/banners';
@import 'elements/program-card';
@import 'elements/course-card';
@import 'elements/icons';
@import 'views/program-list';
@import 'elements/program-card';
@import 'elements-v2/icons';
@import 'views/program-details';
@import 'views/program-list';
......@@ -13,11 +13,12 @@
@import 'base/base';
// base - elements
@import 'elements/typography';
@import 'elements/controls';
@import 'elements/creative-commons';
@import 'elements/icons';
@import 'elements/navigation';
@import 'elements/pagination';
@import 'elements/creative-commons';
@import 'elements/typography';
// shared - course
@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 {
@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-certificate-icon .certificate-body {
fill: palette(success, accent);
border-color: palette(success, accent);
.xseries-icon {
background: url('#{$static-path}/images/programs/xseries-icon.svg') no-repeat;
}
.blue-certificate-icon .certificate-body {
fill: palette(primary, dark);
border-color: palette(primary, dark);
.micromasters-icon {
margin-top: $baseline * 0.05;
background: url('#{$static-path}/images/programs/micromasters-icon.svg') no-repeat;
}
......@@ -97,15 +97,6 @@
width: ($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 {
......
......@@ -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 {
background: transparent;
@include float(right);
......@@ -304,31 +278,11 @@
border-bottom: 4px solid $border-color-l4;
padding-bottom: $baseline;
.course-container{
.course-container {
border: 1px solid $border-color-l4;
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 {
margin-bottom: 0;
border-bottom: none;
......@@ -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 {
.action {
......@@ -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
.prerequisites {
@include clearfix;
......
......@@ -98,8 +98,8 @@ from openedx.core.djangolib.markup import HTML, Text
<% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.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" />
<% 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, related_programs=related_programs" />
% endfor
</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
......@@ -53,12 +53,6 @@ from student.helpers import (
<% mode_class = '' %>
% endif
<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}">
<% course_target = reverse('info', args=[unicode(course_overview.id)]) %>
<section class="details" aria-labelledby="details-heading-${course_overview.number}">
......@@ -283,6 +277,20 @@ from student.helpers import (
</section>
<footer class="wrapper-messages-primary">
<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:
<%include file='_dashboard_certificate_information.html' args='cert_status=cert_status,course_overview=course_overview, enrollment=enrollment, reverify_link=reverify_link'/>
% 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">
<% // 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>
</div>
<div class="action col-12 md-col-4">
<a href="<%- certificate_url %>" class="btn-brand cta-secondary">
<% // 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') %>
</a>
</div>
<div class="message col-12 md-col-8">
<% // 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>
</div>
<div class="action col-12 md-col-4">
<a href="<%- upgrade_url %>" class="btn-brand cta-primary">
<% // 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') %>
</a>
</div>
......@@ -7,6 +7,7 @@ from django.db import models
from config_models.models import ConfigurationModel
# TODO: To be simplified as part of ECOM-5136.
class ProgramsApiConfig(ConfigurationModel):
"""
Manages configuration for connecting to the Programs service and using its
......@@ -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(
verbose_name=_("Path to authoring app's JS"),
max_length=255,
......@@ -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(
verbose_name=_("Path to authoring app's CSS"),
max_length=255,
......@@ -81,7 +80,6 @@ class ProgramsApiConfig(ConfigurationModel):
)
)
# TODO: Remove unused field.
xseries_ad_enabled = models.BooleanField(
verbose_name=_("Do we want to show xseries program advertising"),
default=False
......@@ -117,14 +115,6 @@ class ProgramsApiConfig(ConfigurationModel):
return self.cache_ttl > 0
@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):
"""
Indicates whether Studio functionality related to Programs should
......
......@@ -14,7 +14,7 @@ class Program(factory.Factory):
name = FuzzyText(prefix='Program ')
subtitle = FuzzyText(prefix='Subtitle ')
category = 'FooBar'
status = 'unpublished'
status = 'active'
marketing_slug = FuzzyText(prefix='slug_')
organizations = []
course_codes = []
......
......@@ -16,7 +16,6 @@ class ProgramsApiConfigMixin(object):
'internal_service_url': 'http://internal.programs.org/',
'public_service_url': 'http://public.programs.org/',
'cache_ttl': 0,
'enable_student_dashboard': True,
'enable_studio_tab': True,
'enable_certification': True,
'program_listing_enabled': True,
......
......@@ -36,20 +36,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
programs_config = self.create_programs_config(cache_ttl=cache_ttl)
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):
"""
Verify that the property controlling display of the Studio tab is only True
......
......@@ -12,9 +12,11 @@ from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import timezone
from django.utils.text import slugify
import httpretty
import mock
from nose.plugins.attrib import attr
from opaque_keys.edx.keys import CourseKey
from edx_oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
......@@ -141,36 +143,6 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
actual = utils.get_programs(self.user)
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):
"""Verify programs data can be retrieved and parsed correctly for certificates."""
self.create_programs_config()
......@@ -219,6 +191,78 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, Credential
@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):
"""
Test the get_completed_courses function
......@@ -297,6 +341,14 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
"""Construct a list containing the display names of the indicated 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):
"""Verify behavior when programs exist, but no relevant enrollments do."""
data = [
......@@ -311,7 +363,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
meter = utils.ProgramProgressMeter(self.user)
self.assertEqual(meter.engaged_programs, [])
self.assertEqual(meter.engaged_programs(), [])
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
......@@ -322,7 +374,7 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments('org/course/run')
meter = utils.ProgramProgressMeter(self.user)
self.assertEqual(meter.engaged_programs, [])
self.assertEqual(meter.engaged_programs(), [])
self._assert_progress(meter)
self.assertEqual(meter.completed_programs, [])
......@@ -353,8 +405,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments(course_id)
meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
program = data[0]
self.assertEqual(meter.engaged_programs, [program])
self.assertEqual(meter.engaged_programs(), [program])
self._assert_progress(
meter,
factories.Progress(
......@@ -399,8 +452,9 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
self._create_enrollments(second_course_id, first_course_id)
meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
programs = data[:2]
self.assertEqual(meter.engaged_programs, programs)
self.assertEqual(meter.engaged_programs(), programs)
self._assert_progress(
meter,
factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)),
......@@ -414,15 +468,8 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
appearing in multiple programs.
"""
shared_course_id, solo_course_id = 'org/shared-course/run', 'org/solo-course/run'
data = [
factories.Program(
organizations=[factories.Organization()],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=shared_course_id),
]),
]
),
joint_programs = sorted([
factories.Program(
organizations=[factories.Organization()],
course_codes=[
......@@ -430,7 +477,10 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
factories.RunMode(course_key=shared_course_id),
]),
]
),
) for __ in range(2)
], key=lambda p: p['name'])
data = joint_programs + [
factories.Program(
organizations=[factories.Organization()],
course_codes=[
......@@ -446,14 +496,16 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase):
]
),
]
self._mock_programs_api(data)
# Enrollment for the shared course ID created last (most recently).
self._create_enrollments(solo_course_id, shared_course_id)
meter = utils.ProgramProgressMeter(self.user)
self._attach_detail_url(data)
programs = data[:3]
self.assertEqual(meter.engaged_programs, programs)
self.assertEqual(meter.engaged_programs(), programs)
self._assert_progress(
meter,
factories.Progress(id=programs[0]['id'], in_progress=self._extract_names(programs[0], 0)),
......
......@@ -2,10 +2,11 @@
"""Helper functions for working with Programs."""
import datetime
import logging
from urlparse import urljoin
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify
from opaque_keys.edx.keys import CourseKey
import pytz
......@@ -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)
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):
""" Given a user and an iterable of credentials, get corresponding programs
data and return it as a list of dictionaries.
......@@ -137,24 +81,71 @@ def get_programs_for_credentials(user, programs_credentials):
return certificate_programs
def get_program_detail_url(program, marketing_root):
"""Construct the URL to be used when linking to program details.
def get_programs_by_run(programs, enrollments):
"""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:
program (dict): Representation of a program.
marketing_root (str): Root URL used to build links to program marketing pages.
programs (list): Containing dictionaries representing programs.
enrollments (list): Enrollments from which course IDs to key on can be extracted.
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:
base = reverse('program_details_view', kwargs={'program_id': program['id']}).rstrip('/')
slug = slugify(program['name'])
else:
base = marketing_root.rstrip('/')
slug = program['marketing_slug']
programs_by_run = {}
# enrollment.course_id is really a course key (╯ಠ_ಠ)╯︵ ┻━┻
course_ids = [unicode(e.course_id) for e in enrollments]
for program in programs:
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):
......@@ -182,35 +173,40 @@ class ProgramProgressMeter(object):
Arguments:
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.enrollments = enrollments
self.course_ids = None
self.course_certs = None
self.programs = get_programs(self.user)
self.course_certs = get_completed_courses(self.user)
self.programs = attach_program_detail_url(get_programs(self.user))
@cached_property
def engaged_programs(self):
def engaged_programs(self, by_run=False):
"""Derive a list of programs in which the given user is engaged.
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)
enrollments = sorted(enrollments, 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]
self.enrollments = self.enrollments or list(CourseEnrollment.enrollments_for_user(self.user))
self.enrollments.sort(key=lambda e: e.created, reverse=True)
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 program in flattened.get(course_id, []):
if program not in engaged_programs:
engaged_programs.append(program)
for program in programs_by_run.get(course_id, []):
if program not in programs:
programs.append(program)
return engaged_programs
return programs
@property
def progress(self):
......@@ -221,7 +217,7 @@ class ProgramProgressMeter(object):
towards completing a program.
"""
progress = []
for program in self.engaged_programs:
for program in self.engaged_programs():
completed, in_progress, not_started = [], [], []
for course_code in program['course_codes']:
......@@ -277,6 +273,8 @@ class ProgramProgressMeter(object):
Returns:
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'])
def _is_course_code_in_progress(self, course_code):
......
......@@ -1877,6 +1877,7 @@ class TestGoogleRegistrationView(
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase):
"""Tests the UpdateEmailOptInPreference view. """
......
......@@ -99,8 +99,8 @@ from openedx.core.djangoapps.theming import helpers as theming_helpers
<% is_course_blocked = (enrollment.course_id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.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" />
<% 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, related_programs=related_programs" />
% endfor
</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