From 6f8727a2852d92c9f57a96254735228ee5c97c6f Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Wed, 2 Dec 2015 16:32:36 +0500 Subject: [PATCH 1/7] Create placeholder django apps for credentials ECOM-3057 --- cms/envs/common.py | 2 ++ lms/envs/common.py | 3 +++ openedx/core/djangoapps/credentials/__init__.py | 6 ++++++ openedx/core/djangoapps/credentials/api/__init__.py | 3 +++ openedx/core/djangoapps/credentials/api/urls.py | 3 +++ openedx/core/djangoapps/credentials/api/v1/__init__.py | 0 .../core/djangoapps/credentials/api/v1/tests/__init__.py | 0 openedx/core/djangoapps/credentials/api/v1/views.py | 3 +++ openedx/core/djangoapps/credentials/models.py | 3 +++ openedx/core/djangoapps/credentials/tasks.py | 3 +++ openedx/core/djangoapps/credentials/tests/__init__.py | 0 openedx/core/djangoapps/credentials/urls.py | 3 +++ 12 files changed, 29 insertions(+) create mode 100644 openedx/core/djangoapps/credentials/__init__.py create mode 100644 openedx/core/djangoapps/credentials/api/__init__.py create mode 100644 openedx/core/djangoapps/credentials/api/urls.py create mode 100644 openedx/core/djangoapps/credentials/api/v1/__init__.py create mode 100644 openedx/core/djangoapps/credentials/api/v1/tests/__init__.py create mode 100644 openedx/core/djangoapps/credentials/api/v1/views.py create mode 100644 openedx/core/djangoapps/credentials/models.py create mode 100644 openedx/core/djangoapps/credentials/tasks.py create mode 100644 openedx/core/djangoapps/credentials/tests/__init__.py create mode 100644 openedx/core/djangoapps/credentials/urls.py diff --git a/cms/envs/common.py b/cms/envs/common.py index f41f81b9d6..3a9e2952b6 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -831,6 +831,8 @@ INSTALLED_APPS = ( # Microsite configuration application 'microsite_configuration', + # Credentials support + 'openedx.core.djangoapps.credentials', ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 8c8ae502c6..fd925c39be 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" diff --git a/openedx/core/djangoapps/credentials/__init__.py b/openedx/core/djangoapps/credentials/__init__.py new file mode 100644 index 0000000000..d270c842b3 --- /dev/null +++ b/openedx/core/djangoapps/credentials/__init__.py @@ -0,0 +1,6 @@ +""" +edX Platform support for credentials. + +This package will be used as a wrapper for interacting with the credentials +service to provide support for learners and authors to use features involved. +""" diff --git a/openedx/core/djangoapps/credentials/api/__init__.py b/openedx/core/djangoapps/credentials/api/__init__.py new file mode 100644 index 0000000000..b0bd9acb8f --- /dev/null +++ b/openedx/core/djangoapps/credentials/api/__init__.py @@ -0,0 +1,3 @@ +""" +APIs for the credentials support. +""" diff --git a/openedx/core/djangoapps/credentials/api/urls.py b/openedx/core/djangoapps/credentials/api/urls.py new file mode 100644 index 0000000000..07b6ce31b3 --- /dev/null +++ b/openedx/core/djangoapps/credentials/api/urls.py @@ -0,0 +1,3 @@ +""" +URLs for credential support views. +""" diff --git a/openedx/core/djangoapps/credentials/api/v1/__init__.py b/openedx/core/djangoapps/credentials/api/v1/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/credentials/api/v1/tests/__init__.py b/openedx/core/djangoapps/credentials/api/v1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/credentials/api/v1/views.py b/openedx/core/djangoapps/credentials/api/v1/views.py new file mode 100644 index 0000000000..2aeb708516 --- /dev/null +++ b/openedx/core/djangoapps/credentials/api/v1/views.py @@ -0,0 +1,3 @@ +""" +Credentials API views (v1). +""" diff --git a/openedx/core/djangoapps/credentials/models.py b/openedx/core/djangoapps/credentials/models.py new file mode 100644 index 0000000000..a4baa925ae --- /dev/null +++ b/openedx/core/djangoapps/credentials/models.py @@ -0,0 +1,3 @@ +""" +Models for credentials support for the LMS and Studio. +""" diff --git a/openedx/core/djangoapps/credentials/tasks.py b/openedx/core/djangoapps/credentials/tasks.py new file mode 100644 index 0000000000..ab97fdd25b --- /dev/null +++ b/openedx/core/djangoapps/credentials/tasks.py @@ -0,0 +1,3 @@ +""" +Celery tasks for credentials support views. +""" diff --git a/openedx/core/djangoapps/credentials/tests/__init__.py b/openedx/core/djangoapps/credentials/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/credentials/urls.py b/openedx/core/djangoapps/credentials/urls.py new file mode 100644 index 0000000000..f3257a0a55 --- /dev/null +++ b/openedx/core/djangoapps/credentials/urls.py @@ -0,0 +1,3 @@ +""" +URLs for the credentials support in LMS and Studio. +""" From 2cec045d92e3f328eac2fada6937a8bdc93edbcf Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Wed, 2 Dec 2015 16:32:36 +0500 Subject: [PATCH 2/7] Added configuration model for credentials ECOM-3033 --- openedx/core/djangoapps/credentials/admin.py | 16 +++++ .../credentials/migrations/0001_initial.py | 33 ++++++++++ .../credentials/migrations/__init__.py | 0 openedx/core/djangoapps/credentials/models.py | 63 +++++++++++++++++++ .../djangoapps/credentials/tests/mixins.py | 24 +++++++ .../credentials/tests/test_models.py | 47 ++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 openedx/core/djangoapps/credentials/admin.py create mode 100644 openedx/core/djangoapps/credentials/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/credentials/migrations/__init__.py create mode 100644 openedx/core/djangoapps/credentials/tests/mixins.py create mode 100644 openedx/core/djangoapps/credentials/tests/test_models.py diff --git a/openedx/core/djangoapps/credentials/admin.py b/openedx/core/djangoapps/credentials/admin.py new file mode 100644 index 0000000000..114ff00bbc --- /dev/null +++ b/openedx/core/djangoapps/credentials/admin.py @@ -0,0 +1,16 @@ +""" +django admin pages for credentials support models. +""" + +from django.contrib import admin + +from config_models.admin import ConfigurationModelAdmin + +from openedx.core.djangoapps.credentials.models import CredentialsApiConfig + + +class CredentialsApiConfigAdmin(ConfigurationModelAdmin): # pylint: disable=missing-docstring + pass + + +admin.site.register(CredentialsApiConfig, CredentialsApiConfigAdmin) diff --git a/openedx/core/djangoapps/credentials/migrations/0001_initial.py b/openedx/core/djangoapps/credentials/migrations/0001_initial.py new file mode 100644 index 0000000000..d249c4f649 --- /dev/null +++ b/openedx/core/djangoapps/credentials/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CredentialsApiConfig', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('internal_service_url', models.URLField(verbose_name='Internal Service URL')), + ('public_service_url', models.URLField(verbose_name='Public Service URL')), + ('enable_learner_issuance', models.BooleanField(default=False, help_text='Enable issuance of credentials via Credential Service.', verbose_name='Enable Learner Issuance')), + ('enable_studio_authoring', models.BooleanField(default=False, help_text='Enable authoring of Credential Service credentials in Studio.', verbose_name='Enable Authoring of Credential in Studio')), + ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), + ], + options={ + 'ordering': ('-change_date',), + 'abstract': False, + }, + ), + ] diff --git a/openedx/core/djangoapps/credentials/migrations/__init__.py b/openedx/core/djangoapps/credentials/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/credentials/models.py b/openedx/core/djangoapps/credentials/models.py index a4baa925ae..303c1338c6 100644 --- a/openedx/core/djangoapps/credentials/models.py +++ b/openedx/core/djangoapps/credentials/models.py @@ -1,3 +1,66 @@ """ Models for credentials support for the LMS and Studio. """ + +from urlparse import urljoin + +from django.utils.translation import ugettext_lazy as _ +from django.db import models + +from config_models.models import ConfigurationModel + + +class CredentialsApiConfig(ConfigurationModel): + """ + Manages configuration for connecting to the Credential service and using its + API. + """ + internal_service_url = models.URLField(verbose_name=_("Internal Service URL")) + public_service_url = models.URLField(verbose_name=_("Public Service URL")) + + enable_learner_issuance = models.BooleanField( + verbose_name=_("Enable Learner Issuance"), + default=False, + help_text=_( + "Enable issuance of credentials via Credential Service." + ) + ) + enable_studio_authoring = models.BooleanField( + verbose_name=_("Enable Authoring of Credential in Studio"), + default=False, + help_text=_( + "Enable authoring of Credential Service credentials in Studio." + ) + ) + + def __unicode__(self): + return self.public_api_url + + @property + def internal_api_url(self): + """ + Generate a URL based on internal service URL and API version number. + """ + return urljoin(self.internal_service_url, '/api/v1/') + + @property + def public_api_url(self): + """ + Generate a URL based on public service URL and API version number. + """ + return urljoin(self.public_service_url, '/api/v1/') + + @property + def is_learner_issuance_enabled(self): + """ + Indicates whether the learner credential should be enabled or not. + """ + return self.enabled and self.enable_learner_issuance + + @property + def is_studio_authoring_enabled(self): + """ + Indicates whether Studio functionality related to Credential should + be enabled or not. + """ + return self.enabled and self.enable_studio_authoring diff --git a/openedx/core/djangoapps/credentials/tests/mixins.py b/openedx/core/djangoapps/credentials/tests/mixins.py new file mode 100644 index 0000000000..12270870ed --- /dev/null +++ b/openedx/core/djangoapps/credentials/tests/mixins.py @@ -0,0 +1,24 @@ +"""Mixins for use during testing.""" + +from openedx.core.djangoapps.credentials.models import CredentialsApiConfig + + +class CredentialsApiConfigMixin(object): + """ Utilities for working with Credentials configuration during testing.""" + + DEFAULTS = { + 'enabled': True, + 'internal_service_url': 'http://internal.credentials.org/', + 'public_service_url': 'http://public.credentials.org/', + 'enable_learner_issuance': True, + 'enable_studio_authoring': True, + } + + def create_config(self, **kwargs): + """ Creates a new CredentialsApiConfig with DEFAULTS, updated with any + provided overrides. + """ + fields = dict(self.DEFAULTS, **kwargs) + CredentialsApiConfig(**fields).save() + + return CredentialsApiConfig.current() diff --git a/openedx/core/djangoapps/credentials/tests/test_models.py b/openedx/core/djangoapps/credentials/tests/test_models.py new file mode 100644 index 0000000000..76891c62bb --- /dev/null +++ b/openedx/core/djangoapps/credentials/tests/test_models.py @@ -0,0 +1,47 @@ +"""Tests for models supporting Credentials-related functionality.""" + +from django.test import TestCase +from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin + + +class TestCredentialsApiConfig(CredentialsApiConfigMixin, TestCase): + """Tests covering the CredentialsApiConfig model.""" + def test_url_construction(self): + """Verify that URLs returned by the model are constructed correctly.""" + credentials_config = self.create_config() + + self.assertEqual( + credentials_config.internal_api_url, + credentials_config.internal_service_url.strip('/') + '/api/v1/') + + self.assertEqual( + credentials_config.public_api_url, + credentials_config.public_service_url.strip('/') + '/api/v1/') + + def test_is_learner_issuance_enabled(self): + """ + Verify that the property controlling display on the student dashboard is only True + when configuration is enabled and all required configuration is provided. + """ + credentials_config = self.create_config(enabled=False) + self.assertFalse(credentials_config.is_learner_issuance_enabled) + + credentials_config = self.create_config(enable_learner_issuance=False) + self.assertFalse(credentials_config.is_learner_issuance_enabled) + + credentials_config = self.create_config() + self.assertTrue(credentials_config.is_learner_issuance_enabled) + + def test_is_studio_authoring_enabled(self): + """ + Verify that the property controlling display in the Studio authoring is only True + when configuration is enabled and all required configuration is provided. + """ + credentials_config = self.create_config(enabled=False) + self.assertFalse(credentials_config.is_studio_authoring_enabled) + + credentials_config = self.create_config(enable_studio_authoring=False) + self.assertFalse(credentials_config.is_studio_authoring_enabled) + + credentials_config = self.create_config() + self.assertTrue(credentials_config.is_studio_authoring_enabled) From 4ce58a6f67e987156f4f9f5d73c7cae1467e42ae Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Fri, 25 Dec 2015 20:22:52 +0500 Subject: [PATCH 3/7] Show message for earned programs credentials ECOM-3015 --- .../contentstore/views/tests/test_programs.py | 16 +- common/djangoapps/student/tests/tests.py | 8 +- common/djangoapps/student/views.py | 34 +++++ lms/static/sass/multicourse/_dashboard.scss | 26 ++++ lms/templates/dashboard.html | 13 ++ openedx/core/djangoapps/credentials/models.py | 3 + .../djangoapps/credentials/tests/mixins.py | 137 +++++++++++++++++- .../credentials/tests/test_models.py | 14 +- .../credentials/tests/test_utils.py | 88 +++++++++++ openedx/core/djangoapps/credentials/utils.py | 61 ++++++++ openedx/core/djangoapps/programs/models.py | 1 + .../core/djangoapps/programs/tests/mixins.py | 25 +++- .../djangoapps/programs/tests/test_models.py | 18 +-- .../djangoapps/programs/tests/test_utils.py | 119 +++++++-------- openedx/core/djangoapps/programs/utils.py | 70 ++++----- openedx/core/lib/api_utils.py | 72 +++++++++ openedx/core/lib/tests/test_api_utils.py | 114 +++++++++++++++ 17 files changed, 693 insertions(+), 126 deletions(-) create mode 100644 openedx/core/djangoapps/credentials/tests/test_utils.py create mode 100644 openedx/core/djangoapps/credentials/utils.py create mode 100644 openedx/core/lib/api_utils.py create mode 100644 openedx/core/lib/tests/test_api_utils.py diff --git a/cms/djangoapps/contentstore/views/tests/test_programs.py b/cms/djangoapps/contentstore/views/tests/test_programs.py index 480a9cab01..2bca59f43c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_programs.py +++ b/cms/djangoapps/contentstore/views/tests/test_programs.py @@ -21,6 +21,8 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) + self.create_programs_config() + self.staff = UserFactory(is_staff=True) self.client.login(username=self.staff.username, password='test') @@ -29,7 +31,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 +50,6 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModule student = UserFactory(is_staff=False) self.client.login(username=student.username, password='test') - self.create_config() self.mock_programs_api() response = self.client.get(self.studio_home) @@ -57,7 +58,6 @@ 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.mock_programs_api(data={'results': []}) @@ -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/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..a656d65b4a 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('status') == 'active': + 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/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