diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 13c42268a331b2d026ec370f59152d35c727c0c8..cc03c370c3c6dcfc6ca2335cb3415d3476f069d2 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -2,19 +2,21 @@ """ Miscellaneous tests for the student app. """ -from datetime import datetime, timedelta -import ddt import logging -import pytz import unittest +import ddt +from datetime import datetime, timedelta +from urlparse import urljoin + +import pytz +from mock import Mock, patch +from opaque_keys.edx.locations import SlashSeparatedCourseKey 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 mock import Mock, patch -from opaque_keys.edx.locations import SlashSeparatedCourseKey from student.models import ( anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, @@ -24,6 +26,7 @@ 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 @@ -38,10 +41,12 @@ from certificates.models import CertificateStatuses # pylint: disable=import-er from certificates.tests.factories import GeneratedCertificateFactory # pylint: disable=import-error from 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__) @@ -873,3 +878,237 @@ class AnonymousLookupTable(ModuleStoreTestCase): real_user = user_by_anonymous_id(anonymous_id) self.assertEqual(self.user, real_user) self.assertEqual(anonymous_id, anonymous_id_for_user(self.user, course2.id, save=False)) + + +@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. + """ + def setUp(self): + super(DashboardTestXSeriesPrograms, 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' + + 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() + + def _create_program_data(self, data): + """Dry method to create testing programs data.""" + programs = {} + for course, program_status in data: + programs[unicode(course)] = { + '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 + } + + 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_course_programs_for_dashboard') as mock_data: + mock_data.return_value = { + u'edx/demox/Run_1': { + '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': 'xseries', + 'course_count': len(course_codes), + 'display_name': self.program_name, + 'program_marketing_url': urljoin( + settings.MKTG_URLS.get('ROOT'), 'xseries' + '/{}' + ).format(marketing_slug), + 'display_category': 'XSeries' + } + }, + 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_course_programs_for_dashboard') as mock_method: + mock_method.return_value = self._create_program_data( + [(self.course_1.id, 'active')] + ) + response = self.client.get(reverse('dashboard')) + + # Verify that without the programs configuration the method + # 'get_course_programs_for_dashboard' should not be called + self.assertFalse(mock_method.called) + 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") + self.create_config(enabled=True, enable_student_dashboard=True) + + with patch('student.views.get_course_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) + + @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') + 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") + self.create_config(enabled=True, enable_student_dashboard=True) + + with patch('student.views.get_course_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)] + ) + + 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) + + @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.""" + + CourseEnrollment.enroll(self.user, self.course_1.id) + self.client.login(username="jack", password="test") + self.create_config(enabled=True, enable_student_dashboard=True) + + program_data = self._create_program_data([(self.course_1.id, 'active')]) + if key_remove and key_remove in program_data[unicode(self.course_1.id)]: + del program_data[unicode(self.course_1.id)][key_remove] + + with patch('student.views.get_course_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) + ] + ) + # 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, 'XSeries Program Course', count) + self.assertContains(response, 'XSeries Program: Interested in more courses in this subject?', count) + self.assertContains(response, 'This course is 1 of 3 courses in the', count) + self.assertContains(response, self.program_name, count) + self.assertContains(response, 'View XSeries Details', count) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 24a8b18ef963be55a11f6be74aa0e3e855ad048d..97df4883e930aaaf7f33b7b04392c83856bcd942 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -7,6 +7,8 @@ import uuid import json import warnings from collections import defaultdict +from urlparse import urljoin + from pytz import UTC from requests import HTTPError from ipware.ip import get_ip @@ -579,7 +581,7 @@ def dashboard(request): # program-related information on the dashboard view. course_programs = {} if is_student_dashboard_programs_enabled(): - course_programs = get_course_programs_for_dashboard(user, show_courseware_links_for) + course_programs = _get_course_programs(user, show_courseware_links_for) # Construct a dictionary of course mode information # used to render the course list. We re-use the course modes dict @@ -2271,3 +2273,39 @@ def change_email_settings(request): track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') return JsonResponse({"success": True}) + + +def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invalid-name + """ Returns a dictionary of programs courses data require for the student + dashboard. + + Given a user and an iterable of course keys, find all + the programs relevant to the user and return them in a + dictionary keyed by the course_key. + + Arguments: + user (user object): Currently logged-in User + user_enrolled_courses (list): List of course keys in which user is + enrolled + + Returns: + Dictionary response containing programs or {} + """ + course_programs = get_course_programs_for_dashboard(user, user_enrolled_courses) + programs_data = {} + for course_key, program in course_programs.viewitems(): + if program.get('status') == 'active' and program.get('category') == 'xseries': + try: + programs_data[course_key] = { + 'course_count': len(program['course_codes']), + 'display_name': program['name'], + 'category': program.get('category'), + 'program_marketing_url': urljoin( + settings.MKTG_URLS.get('ROOT'), 'xseries' + '/{}' + ).format(program['marketing_slug']), + 'display_category': 'XSeries' + } + except KeyError: + log.warning('Program structure is invalid, skipping display: %r', program) + + return programs_data diff --git a/lms/static/images/icon-sm-xseries-black.png b/lms/static/images/icon-sm-xseries-black.png new file mode 100644 index 0000000000000000000000000000000000000000..21371576cd35b0cc0d7eccf86a417171b09a8cb5 Binary files /dev/null and b/lms/static/images/icon-sm-xseries-black.png differ diff --git a/lms/static/images/icon-sm-xseries-white.png b/lms/static/images/icon-sm-xseries-white.png new file mode 100644 index 0000000000000000000000000000000000000000..e88282fd8589b51ff58a9454ecc8e8ecff34ee4c Binary files /dev/null and b/lms/static/images/icon-sm-xseries-white.png differ diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss index 0400d9d15de6070f831c5256821a6c07e2deb688..91dd1fdee7af51645615f658d34983eb672d8492 100644 --- a/lms/static/sass/elements/_controls.scss +++ b/lms/static/sass/elements/_controls.scss @@ -345,6 +345,33 @@ } } +%btn-pl-black-border { + @extend %btn-pl-default-base; + border: 1px solid $m-gray-d4; + background-color: transparent; + color: $base-font-color; + + &:hover, + &:focus { + border: 1px solid darken($m-gray-d4,10%); + background-color: $m-gray-d4; + } +} + +%btn-pl-black-base { + @extend %btn-pl-default-base; + border: 1px solid transparent; + background-color: $m-gray-d4; + color: $very-light-text; + + &:hover, + &:focus { + border: 1px solid darken($m-gray-d4,10%); + background-color: transparent; + color: $base-font-color; + } +} + %btn-pl-secondary-base { @extend %btn-pl-default-base; @include transition(border $tmg-f2 ease-in-out); diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 52c87bb66d5b5c1d0088c9783f324c612c656a70..b1dbaa0c06cf9c525d0b09128a2932665899d6e3 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -254,6 +254,27 @@ .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; @@ -776,6 +797,100 @@ } } + .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; + 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; + 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 { diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 6ef940a4a1ed639758308c5c45607bcea73e6d17..8755a7f8d248eeec2344f5fe5c832d2c3ce80dc7 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -95,7 +95,8 @@ import json <% 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) %> - <%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, 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_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, 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" /> % endfor </ul> diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index da54ab5bd7db628d9ee3b31f497ed6d692da6257..e73780761c8573407ed160d509e6aa3c4261e86c 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course_overview, enrollment, show_courseware_link, cert_status, 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" /> +<%page args="course_overview, enrollment, show_courseware_link, cert_status, 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" /> <%! import urllib @@ -44,6 +44,12 @@ from student.helpers import ( <% mode_class = '' %> % endif <div class="course-container"> + % if course_program_info and course_program_info['category']=='xseries': + <div class="label-xseries-association"> + <i class="xseries-icon"></i> + <p class="message-copy">${_("{category} Program Course").format(category=course_program_info['display_category'])}</p> + </div> + % endif <article class="course${mode_class}"> <% course_target = reverse('info', args=[unicode(course_overview.id)]) %> <section class="details"> @@ -342,6 +348,10 @@ from student.helpers import ( </div> %endif + % if course_program_info and course_program_info['category']=='xseries': + <%include file = "_dashboard_xseries_info.html" args="course_program_info=course_program_info, enrollment_mode=enrollment.mode" /> + % endif + % if is_course_blocked: <p id="block-course-msg" class="course-block"> ${_("You can no longer access this course because payment has not yet been received. " diff --git a/lms/templates/dashboard/_dashboard_xseries_info.html b/lms/templates/dashboard/_dashboard_xseries_info.html new file mode 100644 index 0000000000000000000000000000000000000000..7420310c7bd9e98248b246d4eceb587b270902e8 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_xseries_info.html @@ -0,0 +1,35 @@ +<%page args="course_program_info, enrollment_mode" /> +<%! + from django.utils.translation import ugettext as _ +%> +<%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> + <b class="message-copy-bold">${_("{category} Program: Interested in more courses in this subject?").format(category=course_program_info['display_category'])}</b> + <p> + <p class="message-copy"> + ${_("This course is 1 of {course_count} courses in the {link_start}{program_display_name}{link_end} {program_category}.").format( + course_count=course_program_info['course_count'], + link_start='<a href="{}">'.format(course_program_info['program_marketing_url']), + link_end='</a>', + program_display_name=course_program_info['display_name'], + program_category=course_program_info['display_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="${course_program_info['program_marketing_url']}" target="_blank"> + <i class="action-xseries-icon"></i> + <span> + ${_("View {category} Details").format(category=course_program_info['display_category'])} + </span> + </a> + </div> +</div> diff --git a/openedx/core/djangoapps/programs/tests/test_views.py b/openedx/core/djangoapps/programs/tests/test_views.py index f9aceeff81dc2c1f0e78b9a3b6c3cad4bb2a146a..9291e028e627d164f6e0ea767c98f9122bfa032b 100644 --- a/openedx/core/djangoapps/programs/tests/test_views.py +++ b/openedx/core/djangoapps/programs/tests/test_views.py @@ -33,35 +33,37 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): 'status': 'active', 'subtitle': 'Dummy program 1 for testing', 'name': 'First Program', + 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, 'course_codes': [ { 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, 'display_name': 'Demo XSeries Program 1', 'key': 'TEST_A', - 'marketing_slug': 'fake-marketing-slug-xseries-1', 'run_modes': [ {'sku': '', 'mode_slug': 'ABC_1', 'course_key': 'edX/DemoX_1/Run_1'}, {'sku': '', 'mode_slug': 'ABC_2', 'course_key': 'edX/DemoX_2/Run_2'}, ] } - ] + ], + 'marketing_slug': 'fake-marketing-slug-xseries-1', }, { 'category': 'xseries', 'status': 'active', 'subtitle': 'Dummy program 2 for testing', 'name': 'Second Program', + 'organization': {'display_name': 'Test Organization 2', 'key': 'edX'}, 'course_codes': [ { 'organization': {'display_name': 'Test Organization 2', 'key': 'edX'}, 'display_name': 'Demo XSeries Program 2', 'key': 'TEST_B', - 'marketing_slug': 'fake-marketing-slug-xseries-2', 'run_modes': [ {'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'}, ] } - ] + ], + 'marketing_slug': 'fake-marketing-slug-xseries-2', } ] } @@ -83,10 +85,11 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): 'edX/DemoX_1/Run_1': { 'category': 'xseries', 'status': 'active', + 'subtitle': 'Dummy program 1 for testing', + 'name': 'First Program', 'course_codes': [ { 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, - 'marketing_slug': 'fake-marketing-slug-xseries-1', 'display_name': 'Demo XSeries Program 1', 'key': 'TEST_A', 'run_modes': [ @@ -95,16 +98,17 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): ] } ], - 'subtitle': 'Dummy program 1 for testing', - 'name': 'First Program' + 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, + 'marketing_slug': 'fake-marketing-slug-xseries-1', }, 'edX/DemoX_2/Run_2': { 'category': 'xseries', 'status': 'active', + 'subtitle': 'Dummy program 1 for testing', + 'name': 'First Program', 'course_codes': [ { 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, - 'marketing_slug': 'fake-marketing-slug-xseries-1', 'display_name': 'Demo XSeries Program 1', 'key': 'TEST_A', 'run_modes': [ @@ -113,8 +117,8 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): ] } ], - 'subtitle': 'Dummy program 1 for testing', - 'name': 'First Program' + 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, + 'marketing_slug': 'fake-marketing-slug-xseries-1', }, } self.assertTrue(mock_get.called) @@ -131,10 +135,11 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): expected_output['edX/Program/Program_Run'] = { 'category': 'xseries', 'status': 'active', + 'subtitle': 'Dummy program 2 for testing', + 'name': 'Second Program', 'course_codes': [ { 'organization': {'display_name': 'Test Organization 2', 'key': 'edX'}, - 'marketing_slug': 'fake-marketing-slug-xseries-2', 'display_name': 'Demo XSeries Program 2', 'key': 'TEST_B', 'run_modes': [ @@ -142,8 +147,8 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): ] } ], - 'subtitle': 'Dummy program 2 for testing', - 'name': 'Second Program' + 'organization': {'display_name': 'Test Organization 2', 'key': 'edX'}, + 'marketing_slug': 'fake-marketing-slug-xseries-2', } self.assertTrue(mock_get.called) self.assertEqual(expected_output, programs) @@ -206,3 +211,37 @@ class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): get_course_programs_for_dashboard(self.user, ['edX/DemoX/Run']), {} ) self.assertTrue(mock_get.called) + + @patch('openedx.core.djangoapps.programs.views.log.exception') + def test_get_course_programs_with_invalid_response(self, log_exception): + """ Test that the method 'get_course_programs_for_dashboard' logs + the exception message if rest api client returns invalid data. + """ + program = { + 'category': 'xseries', + 'status': 'active', + 'subtitle': 'Dummy program 1 for testing', + 'name': 'First Program', + 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, + 'course_codes': [ + { + 'organization': {'display_name': 'Test Organization 1', 'key': 'edX'}, + 'display_name': 'Demo XSeries Program 1', + 'key': 'TEST_A', + 'run_modes': [ + {'sku': '', 'mode_slug': 'ABC_2'}, + ] + } + ], + 'marketing_slug': 'fake-marketing-slug-xseries-1', + } + invalid_programs_api_response = {"results": [program]} + # mock the request call + with patch('slumber.Resource.get') as mock_get: + mock_get.return_value = invalid_programs_api_response + programs = get_course_programs_for_dashboard(self.user, ['edX/DemoX/Run']) + log_exception.assert_called_with( + 'Unable to parse Programs API response: %r', + program + ) + self.assertEqual(programs, {}) diff --git a/openedx/core/djangoapps/programs/views.py b/openedx/core/djangoapps/programs/views.py index d9b45c1674415a24306dd084723a346fa72cd53f..f3e404cfbb57dfa3f73906ef1a5358da89c73a7e 100644 --- a/openedx/core/djangoapps/programs/views.py +++ b/openedx/core/djangoapps/programs/views.py @@ -60,9 +60,12 @@ def get_course_programs_for_dashboard(user, course_keys): # pylint: disable=in # to # course run -> program, ignoring course runs not present in the dashboard enrollments for program in programs: - for course_code in program['course_codes']: - for run in course_code['run_modes']: - if run['course_key'] in course_keys: - course_programs[run['course_key']] = program + try: + for course_code in program['course_codes']: + for run in course_code['run_modes']: + if run['course_key'] in course_keys: + course_programs[run['course_key']] = program + except KeyError: + log.exception('Unable to parse Programs API response: %r', program) return course_programs