From c47311b11fe2d67bd022acb768cce7e86fcdc214 Mon Sep 17 00:00:00 2001
From: Ahsan Ulhaq <ahsan.haq@arbisoft.com>
Date: Tue, 27 Oct 2015 17:13:14 +0500
Subject: [PATCH] Add the html and css and backend changes required for the
 xseries.

---
 common/djangoapps/student/tests/tests.py      | 249 +++++++++++++++++-
 common/djangoapps/student/views.py            |  40 ++-
 lms/static/images/icon-sm-xseries-black.png   | Bin 0 -> 711 bytes
 lms/static/images/icon-sm-xseries-white.png   | Bin 0 -> 706 bytes
 lms/static/sass/elements/_controls.scss       |  27 ++
 lms/static/sass/multicourse/_dashboard.scss   | 115 ++++++++
 lms/templates/dashboard.html                  |   3 +-
 .../dashboard/_dashboard_course_listing.html  |  12 +-
 .../dashboard/_dashboard_xseries_info.html    |  35 +++
 .../djangoapps/programs/tests/test_views.py   |  65 ++++-
 openedx/core/djangoapps/programs/views.py     |  11 +-
 11 files changed, 532 insertions(+), 25 deletions(-)
 create mode 100644 lms/static/images/icon-sm-xseries-black.png
 create mode 100644 lms/static/images/icon-sm-xseries-white.png
 create mode 100644 lms/templates/dashboard/_dashboard_xseries_info.html

diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 13c42268a33..cc03c370c3c 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 24a8b18ef96..97df4883e93 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
GIT binary patch
literal 711
zcmV;&0yzDNP)<h;3K|Lk000e1NJLTq000sI000sQ1^@s6R?d!B0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!X-PyuRCwCdQ_G9fKpdV-CYel{q}{Y>
zYWJb&RXqCy5kwXb>Sb@<z4#A^hYBJH3Z5!nJb84{;~u@}LH`0pSrtV3ZnjOE*G&9n
z%`WTO?t(W5@|fS_oA3K(Mn;E>qG>rtmzGWmk;Xw7`19)U{y?qjmKkGG_HUOK7G1}A
zB7}JD*yU!$wwJTGMk%Sgwc~SvsugF|(Dn6dx%~XZ%CU0^PddHfV4x7<4!68<7=|r~
zpLQ!wQ)lc&&2g^H1X2|xqjFwTwHw}OM901#rD^&Y;#X6I?)#HTG#q(Eqgn=VMpaZ}
zr<d#nk|gOfA*6w^yckAd4<w<{c)a=L+t<w~j*&=H6og?HYStwo{Yc{Y!)|Z(0_`hQ
zr4rC@qZBC^V`CZyUXmn#nM-7uF^$<G6&|pimaJkuPGT&9&XY#Lcqn6RPJ`gV&vt7g
zPLl1Wg0553FfIeLvsl9K#}j`8<OW$zmSuFL>Rtk`8jzooAdI${#;<@ZIf|nK7b5db
z00?sIib<)umWuQy<X=)s-|89*fGq+*1acLoX%{jJ2*~+fDi&|6s#<S#yKjKD(3y4}
zfF@w_N07&IRSb5IX{FMt5k)H?>sh<oc?Y)K6I}GS%^rH*Qy*5a3=`SK;u@^{DB=7S
z7yJ{XqZ}xPW(>d=WkPN_<qDOt+yt-&=lu8f11r?&_mE|pmZB(k!K3BONJ#O0UJH19
zkYa)9cNQ$m?e_a<;CX+ZBPg%|K;+Z(psNFQeejdbyCQrBi-U%kd)NMT{2xHlAzw6W
tJ_{($P24d($>+%)gGFAqyR*Lp7y!`j9YN7IkbeLG002ovPDHLkV1jmGM$rHO

literal 0
HcmV?d00001

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
GIT binary patch
literal 706
zcmV;z0zLhSP)<h;3K|Lk000e1NJLTq000sI000sQ1^@s6R?d!B0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!WJyFpRCwCdQ%`7;VH|$vZ1bBdacdVp
zT#z@hos~L7LODxXN_h}r2O+xz;;Bmv3P~YC6vPljK@f9#$%7%L9r{D=U<Xg(m?(@e
zb!;~9-`Dfm=h(>M=++M&-tYH&-{14S&-1?TG?@@Kn{6qPNUR8j!frmFZ<Dh|DwSH)
zXf#vox5MF}`~7}|VVK>)V6f@&c%*DyrBd;8x!mPbNy6dqDjIXOS}nM<vvbvIwa&O)
zE-#1}pU)REo6R$oN<|60M(JZb9=|*4<sB!I%jFc0$GawxNbbdAvDs)e+CljxaGTHP
zKTjr;olq#mmrA89fl8rJ%#V8c$B6_2!FLQB09`7TezsaI5(osgGnveGwOS<y2M45B
zEY>l<3Dn@**HQ1#3%A?7Ow;sKBog`2>2%J4aK2C|>_OgN=3*VCR4Ny+ZV&Pf*Vor2
z^?IF0AVGmb3iyD~$YipkCcR!i3n_O12e4)`nFOOb$S2us_6FMrq>0^bUv4xSJ=V}*
zFlZ2q#Y5i=1_LRV%NJ1&Gk@X@<Slu<UShFW2t`rE@AuzgC-(REnISv8c!d1f+}zxv
zPN!p!eGRg1qIQWT;E3J=%fKpr{LpALvaRQlZ$RebX*TQidWEg6t@n_1=yW<CXtml`
zB9W+%sn5W5;0jOz-U0Wqn<r|u`d7Q%{si;0>2&(b2_tb*7x0H~+zws<QXs<RNp43^
zxqKJ*jUIcCn$6|{h5?r{6CObWr$vn=Li9PDH3p{<{d4>uL{k$+GA=(sgqP0_UCzXj
o={+v*9W$6k7weBs_O}2704PvcnsOKEod5s;07*qoM6N<$g73aWQvd(}

literal 0
HcmV?d00001

diff --git a/lms/static/sass/elements/_controls.scss b/lms/static/sass/elements/_controls.scss
index 0400d9d15de..91dd1fdee7a 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 52c87bb66d5..b1dbaa0c06c 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 6ef940a4a1e..8755a7f8d24 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 da54ab5bd7d..e73780761c8 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 00000000000..7420310c7bd
--- /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 f9aceeff81d..9291e028e62 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 d9b45c16744..f3e404cfbb5 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
-- 
GitLab