diff --git a/cms/djangoapps/contentstore/views/tests/test_programs.py b/cms/djangoapps/contentstore/views/tests/test_programs.py index 480a9cab01..4ec26edac8 100644 --- a/cms/djangoapps/contentstore/views/tests/test_programs.py +++ b/cms/djangoapps/contentstore/views/tests/test_programs.py @@ -29,7 +29,7 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule @httpretty.activate def test_programs_config_disabled(self): """Verify that the programs tab and creation button aren't rendered when config is disabled.""" - self.create_config(enable_studio_tab=False) + self.create_programs_config(enable_studio_tab=False) self.mock_programs_api() response = self.client.get(self.studio_home) @@ -48,7 +48,7 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule student = UserFactory(is_staff=False) self.client.login(username=student.username, password='test') - self.create_config() + self.create_programs_config() self.mock_programs_api() response = self.client.get(self.studio_home) @@ -57,9 +57,9 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule @httpretty.activate def test_programs_displayed(self): """Verify that the programs tab and creation button can be rendered when config is enabled.""" - self.create_config() # When no data is provided, expect creation prompt. + self.create_programs_config() self.mock_programs_api(data={'results': []}) response = self.client.get(self.studio_home) @@ -102,7 +102,7 @@ class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase def test_authoring_header(self): """Verify that the header contains the expected text.""" self.client.login(username=self.staff.username, password='test') - self.create_config() + self.create_programs_config() response = self._assert_status(200) self.assertIn("Program Administration", response.content) @@ -116,7 +116,7 @@ class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase self._assert_status(404) # Enable Programs authoring interface - self.create_config() + self.create_programs_config() student = UserFactory(is_staff=False) self.client.login(username=student.username, password='test') @@ -134,13 +134,13 @@ class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase) def test_config_disabled(self): """Ensure the endpoint returns 404 when Programs authoring is disabled.""" - self.create_config(enable_studio_tab=False) + self.create_programs_config(enable_studio_tab=False) response = self.client.get(self.path) self.assertEqual(response.status_code, 404) def test_not_logged_in(self): """Ensure the endpoint denies access to unauthenticated users.""" - self.create_config() + self.create_programs_config() self.client.logout() response = self.client.get(self.path) self.assertEqual(response.status_code, 302) @@ -152,7 +152,7 @@ class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase) Ensure the endpoint responds with a valid JSON payload when authoring is enabled. """ - self.create_config() + self.create_programs_config() response = self.client.get(self.path) self.assertEqual(response.status_code, 200) payload = json.loads(response.content) diff --git a/cms/envs/common.py b/cms/envs/common.py index a34044261f..2da76905b2 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -839,6 +839,8 @@ INSTALLED_APPS = ( # Microsite configuration application 'microsite_configuration', + # Credentials support + 'openedx.core.djangoapps.credentials', ) diff --git a/cms/envs/test.py b/cms/envs/test.py index 8577e82a3b..1528948abb 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -132,6 +132,7 @@ DATABASES = { # This hack disables migrations during tests. We want to create tables directly from the models for speed. # See https://groups.google.com/d/msg/django-developers/PWPj3etj3-U/kCl6pMsQYYoJ. MIGRATION_MODULES = {app: "app.migrations_not_used_in_tests" for app in INSTALLED_APPS} +MIGRATION_MODULES["credentials"] = "app.migrations_not_used_in_tests" LMS_BASE = "localhost:8000" FEATURES['PREVIEW_LMS_BASE'] = "preview" diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 5d9cb96f1f..fcd1a87fed 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -1034,7 +1034,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): CourseEnrollment.enroll(self.user, self.course_2.id, mode=course_mode) self.client.login(username="jack", password="test") - self.create_config() + self.create_programs_config() with patch('student.views.get_programs_for_dashboard') as mock_data: mock_data.return_value = self._create_program_data( @@ -1067,7 +1067,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified') self.client.login(username="jack", password="test") - self.create_config() + self.create_programs_config() with patch( 'student.views.get_programs_for_dashboard', @@ -1097,7 +1097,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor') self.client.login(username="jack", password="test") - self.create_config() + self.create_programs_config() with patch('student.views.get_programs_for_dashboard') as mock_data: mock_data.return_value = self._create_program_data( @@ -1118,7 +1118,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): CourseEnrollment.enroll(self.user, self.course_1.id) self.client.login(username="jack", password="test") - self.create_config() + self.create_programs_config() 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)]: diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ad248bd901..d0b7f1427b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -123,6 +123,7 @@ from eventtracking import tracker from notification_prefs.views import enable_notifications # Note that this lives in openedx, so this dependency should be refactored. +from openedx.core.djangoapps.credentials.utils import get_user_program_credentials from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard @@ -607,6 +608,7 @@ def dashboard(request): # 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]) + xseries_credentials = _get_xseries_credentials(user) # Construct a dictionary of course mode information # used to render the course list. We re-use the course modes dict @@ -730,6 +732,7 @@ def dashboard(request): 'nav_hidden': True, 'course_programs': course_programs, 'disable_courseware_js': True, + 'xseries_credentials': xseries_credentials, } return render_to_response('dashboard.html', context) @@ -2408,3 +2411,34 @@ def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invali log.warning('Program structure is invalid, skipping display: %r', program) return programs_data + + +def _get_xseries_credentials(user): + """Return program credentials data required for display on + the learner dashboard. + + Given a user, find all programs for which certificates have been earned + and return list of dictionaries of required program data. + + Arguments: + user (User): user object for getting programs credentials. + + Returns: + list of dict, containing data corresponding to the programs for which + the user has been awarded a credential. + """ + programs_credentials = get_user_program_credentials(user) + credentials_data = [] + for program in programs_credentials: + if program.get('category') == 'xseries': + try: + program_data = { + 'display_name': program['name'], + 'subtitle': program['subtitle'], + 'credential_url': program['credential_url'], + } + credentials_data.append(program_data) + except KeyError: + log.warning('Program structure is invalid: %r', program) + + return credentials_data diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 4a337f7056..7a38777b79 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -727,6 +727,7 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U #### JWT configuration #### JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER) JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION) +JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) ################# PROCTORING CONFIGURATION ################## diff --git a/lms/envs/common.py b/lms/envs/common.py index ad3ceda610..68fcc3e6c7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1924,6 +1924,9 @@ INSTALLED_APPS = ( 'openedx.core.djangoapps.self_paced', 'sorl.thumbnail', + + # Credentials support + 'openedx.core.djangoapps.credentials', ) # Migrations which are not in the standard module "migrations" @@ -2000,6 +2003,18 @@ SOCIAL_MEDIA_FOOTER_NAMES = [ "reddit", ] +# JWT Settings +JWT_AUTH = { + 'JWT_SECRET_KEY': None, + 'JWT_ALGORITHM': 'HS256', + 'JWT_VERIFY_EXPIRATION': True, + 'JWT_ISSUER': None, + 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'), + 'JWT_AUDIENCE': None, + 'JWT_LEEWAY': 1, + 'JWT_DECODE_HANDLER': 'openedx.core.lib.api.jwt_decode_handler.decode', +} + # The footer URLs dictionary maps social footer names # to URLs defined in configuration. SOCIAL_MEDIA_FOOTER_URLS = {} @@ -2554,6 +2569,9 @@ ECOMMERCE_API_SIGNING_KEY = None ECOMMERCE_API_TIMEOUT = 5 ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker' +# Credentials configuration +CREDENTIALS_SERVICE_USERNAME = 'credentials_service_user' + # Reverification checkpoint name pattern CHECKPOINT_PATTERN = r'(?P[^/]+)' diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 95a8572a67..3445bf21a5 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -224,6 +224,13 @@ CORS_ALLOW_CREDENTIALS = True CORS_ORIGIN_WHITELIST = () CORS_ORIGIN_ALLOW_ALL = True +# JWT settings for devstack +JWT_AUTH.update({ + 'JWT_ALGORITHM': 'HS256', + 'JWT_SECRET_KEY': 'lms-secret', + 'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2', + 'JWT_AUDIENCE': 'lms-key', +}) ##################################################################### # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index 4ff6fd30f5..f5227081bb 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -190,6 +190,7 @@ DATABASES = { # This hack disables migrations during tests. We want to create tables directly from the models for speed. # See https://groups.google.com/d/msg/django-developers/PWPj3etj3-U/kCl6pMsQYYoJ. MIGRATION_MODULES = {app: "app.migrations_not_used_in_tests" for app in INSTALLED_APPS} +MIGRATION_MODULES["credentials"] = "app.migrations_not_used_in_tests" CACHES = { # This is the cache used for most things. @@ -562,3 +563,9 @@ FEATURES['ORGANIZATIONS_APP'] = True # Financial assistance page FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True + +JWT_AUTH.update({ + 'JWT_SECRET_KEY': 'test-secret', + 'JWT_ISSUER': 'https://test-provider/oauth2', + 'JWT_AUDIENCE': 'test-key', +}) diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 1901dcae37..a1eab8972b 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -32,6 +32,32 @@ } } + .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); diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index b5ff573f70..1fe6a6213e 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -180,6 +180,19 @@ import json + % if xseries_credentials: +
+

${_("XSeries Program Certificates")}

+

${_("You have received a certificate for the following XSeries programs:")}

+ +
+ % endif