From 7f96474d015a7ccf5fe4b23469e6a5720fcde0ee Mon Sep 17 00:00:00 2001 From: zubair-arbi Date: Thu, 22 Oct 2015 15:02:51 +0500 Subject: [PATCH] basic programs api setup and dashboard integration ECOM-2578 --- common/djangoapps/student/views.py | 11 +- lms/djangoapps/edxnotes/decorators.py | 9 +- lms/djangoapps/edxnotes/helpers.py | 54 ++--- lms/djangoapps/edxnotes/tests.py | 6 +- lms/djangoapps/edxnotes/views.py | 5 +- openedx/core/djangoapps/programs/__init__.py | 10 + .../core/djangoapps/programs/tests/mixins.py | 27 +++ .../djangoapps/programs/tests/test_models.py | 22 +- .../djangoapps/programs/tests/test_views.py | 208 ++++++++++++++++++ openedx/core/djangoapps/programs/utils.py | 22 ++ openedx/core/djangoapps/programs/views.py | 68 ++++++ openedx/core/djangoapps/util/helpers.py | 48 ++++ .../core/djangoapps/util/tests/__init__.py | 0 .../djangoapps/util/tests/test_helpers.py | 45 ++++ requirements/edx/base.txt | 2 +- 15 files changed, 476 insertions(+), 61 deletions(-) create mode 100644 openedx/core/djangoapps/programs/tests/mixins.py create mode 100644 openedx/core/djangoapps/programs/tests/test_views.py create mode 100644 openedx/core/djangoapps/programs/utils.py create mode 100644 openedx/core/djangoapps/programs/views.py create mode 100644 openedx/core/djangoapps/util/helpers.py create mode 100644 openedx/core/djangoapps/util/tests/__init__.py create mode 100644 openedx/core/djangoapps/util/tests/test_helpers.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 76941f07ed..24a8b18ef9 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -28,7 +28,6 @@ from django.shortcuts import redirect from django.utils.translation import ungettext from django.utils.http import base36_to_int from django.utils.translation import ugettext as _, get_language -from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.http import require_POST, require_GET from django.db.models.signals import post_save @@ -123,6 +122,8 @@ from notification_prefs.views import enable_notifications # Note that this lives in openedx, so this dependency should be refactored. from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.core.djangoapps.programs.views import get_course_programs_for_dashboard +from openedx.core.djangoapps.programs.utils import is_student_dashboard_programs_enabled log = logging.getLogger("edx.student") @@ -573,6 +574,13 @@ def dashboard(request): and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview) ) + # get the programs associated with courses being displayed. + # pass this along in template context in order to render additional + # 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) + # Construct a dictionary of course mode information # used to render the course list. We re-use the course modes dict # we loaded earlier to avoid hitting the database. @@ -693,6 +701,7 @@ def dashboard(request): 'order_history_list': order_history_list, 'courses_requirements_not_met': courses_requirements_not_met, 'nav_hidden': True, + 'course_programs': course_programs, } return render_to_response('dashboard.html', context) diff --git a/lms/djangoapps/edxnotes/decorators.py b/lms/djangoapps/edxnotes/decorators.py index 8abb30a715..232afa08da 100644 --- a/lms/djangoapps/edxnotes/decorators.py +++ b/lms/djangoapps/edxnotes/decorators.py @@ -1,11 +1,14 @@ """ Decorators related to edXNotes. """ -from django.conf import settings + import json + +from django.conf import settings + from edxnotes.helpers import ( + get_edxnotes_id_token, get_public_endpoint, - get_id_token, get_token_url, generate_uid, is_feature_enabled, @@ -43,7 +46,7 @@ def edxnotes(cls): # Use camelCase to name keys. "usageId": unicode(self.scope_ids.usage_id).encode("utf-8"), "courseId": unicode(self.runtime.course_id).encode("utf-8"), - "token": get_id_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)), + "token": get_edxnotes_id_token(self.runtime.get_real_user(self.runtime.anonymous_student_id)), "tokenUrl": get_token_url(self.runtime.course_id), "endpoint": get_public_endpoint(), "debug": settings.DEBUG, diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 2e1044981b..88f8f28823 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -1,35 +1,39 @@ """ Helper methods related to EdxNotes. """ + import json import logging -import requests -from requests.exceptions import RequestException -from uuid import uuid4 from json import JSONEncoder +from uuid import uuid4 + +import requests from datetime import datetime -from courseware.access import has_access -from courseware.views import get_current_child +from dateutil.parser import parse as dateutil_parse +from opaque_keys.edx.keys import UsageKey +from requests.exceptions import RequestException + from django.conf import settings -from django.core.urlresolvers import reverse from django.core.exceptions import ImproperlyConfigured +from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ +from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable from capa.util import sanitize_html +from courseware.views import get_current_child +from courseware.access import has_access +from openedx.core.djangoapps.util.helpers import get_id_token from student.models import anonymous_id_for_user +from util.date_utils import get_default_time_display from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from util.date_utils import get_default_time_display -from dateutil.parser import parse as dateutil_parse -from provider.oauth2.models import AccessToken, Client -import oauth2_provider.oidc as oidc -from provider.utils import now -from opaque_keys.edx.keys import UsageKey -from .exceptions import EdxNotesParseError, EdxNotesServiceUnavailable + log = logging.getLogger(__name__) HIGHLIGHT_TAG = "span" HIGHLIGHT_CLASS = "note-highlight" +# OAuth2 Client name for edxnotes +CLIENT_NAME = "edx-notes" class NoteJSONEncoder(JSONEncoder): @@ -43,27 +47,11 @@ class NoteJSONEncoder(JSONEncoder): return json.JSONEncoder.default(self, obj) -def get_id_token(user): +def get_edxnotes_id_token(user): """ - Generates JWT ID-Token, using or creating user's OAuth access token. + Returns generated ID Token for edxnotes. """ - try: - client = Client.objects.get(name="edx-notes") - except Client.DoesNotExist: - raise ImproperlyConfigured("OAuth2 Client with name 'edx-notes' is not present in the DB") - try: - access_token = AccessToken.objects.get( - client=client, - user=user, - expires__gt=now() - ) - except AccessToken.DoesNotExist: - access_token = AccessToken(client=client, user=user) - access_token.save() - - id_token = oidc.id_token(access_token) - secret = id_token.access_token.client.client_secret - return id_token.encode(secret) + return get_id_token(user, CLIENT_NAME) def get_token_url(course_id): @@ -97,7 +85,7 @@ def send_request(user, course_id, path="", query_string=None): response = requests.get( url, headers={ - "x-annotator-auth-token": get_id_token(user) + "x-annotator-auth-token": get_edxnotes_id_token(user) }, params=params ) diff --git a/lms/djangoapps/edxnotes/tests.py b/lms/djangoapps/edxnotes/tests.py index 24b5b3a226..9916cff37f 100644 --- a/lms/djangoapps/edxnotes/tests.py +++ b/lms/djangoapps/edxnotes/tests.py @@ -81,7 +81,7 @@ class EdxNotesDecoratorTest(ModuleStoreTestCase): @patch.dict("django.conf.settings.FEATURES", {'ENABLE_EDXNOTES': True}) @patch("edxnotes.decorators.get_public_endpoint") @patch("edxnotes.decorators.get_token_url") - @patch("edxnotes.decorators.get_id_token") + @patch("edxnotes.decorators.get_edxnotes_id_token") @patch("edxnotes.decorators.generate_uid") def test_edxnotes_enabled(self, mock_generate_uid, mock_get_id_token, mock_get_token_url, mock_get_endpoint): """ @@ -691,7 +691,7 @@ class EdxNotesHelpersTest(ModuleStoreTestCase): @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("edxnotes.helpers.anonymous_id_for_user") - @patch("edxnotes.helpers.get_id_token") + @patch("edxnotes.helpers.get_edxnotes_id_token") @patch("edxnotes.helpers.requests.get") def test_send_request_with_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user): """ @@ -720,7 +720,7 @@ class EdxNotesHelpersTest(ModuleStoreTestCase): @override_settings(EDXNOTES_PUBLIC_API="http://example.com") @override_settings(EDXNOTES_INTERNAL_API="http://example.com") @patch("edxnotes.helpers.anonymous_id_for_user") - @patch("edxnotes.helpers.get_id_token") + @patch("edxnotes.helpers.get_edxnotes_id_token") @patch("edxnotes.helpers.requests.get") def test_send_request_without_query_string(self, mock_get, mock_get_id_token, mock_anonymous_id_for_user): """ diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index 1cc230d330..9a1e5eb64c 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -15,13 +15,14 @@ from courseware.module_render import get_module_for_descriptor from util.json_request import JsonResponse, JsonResponseBadRequest from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable from edxnotes.helpers import ( + get_edxnotes_id_token, get_notes, - get_id_token, is_feature_enabled, search, get_course_position, ) + log = logging.getLogger(__name__) @@ -94,7 +95,7 @@ def get_token(request, course_id): """ Get JWT ID-Token, in case you need new one. """ - return HttpResponse(get_id_token(request.user), content_type='text/plain') + return HttpResponse(get_edxnotes_id_token(request.user), content_type='text/plain') @login_required diff --git a/openedx/core/djangoapps/programs/__init__.py b/openedx/core/djangoapps/programs/__init__.py index e69de29bb2..a6287c38bf 100644 --- a/openedx/core/djangoapps/programs/__init__.py +++ b/openedx/core/djangoapps/programs/__init__.py @@ -0,0 +1,10 @@ +""" +Platform support for Programs. + +This package is a thin wrapper around interactions with the Programs service, +supporting learner- and author-facing features involving that service +if and only if the service is deployed in the Open edX installation. + +To ensure maximum separation of concerns, and a minimum of interdependencies, +this package should be kept small, thin, and stateless. +""" diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py new file mode 100644 index 0000000000..0b8fde0333 --- /dev/null +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -0,0 +1,27 @@ +""" +Broadly-useful mixins for use in automated tests. +""" + +from openedx.core.djangoapps.programs.models import ProgramsApiConfig + + +class ProgramsApiConfigMixin(object): + """ + Programs api configuration utility methods for testing. + """ + + INTERNAL_URL = "http://internal/" + PUBLIC_URL = "http://public/" + + DEFAULTS = dict( + internal_service_url=INTERNAL_URL, + public_service_url=PUBLIC_URL, + api_version_number=1, + ) + + def create_config(self, **kwargs): + """ + DRY helper. Create a new ProgramsApiConfig with self.DEFAULTS, updated + with any kwarg overrides. + """ + ProgramsApiConfig(**dict(self.DEFAULTS, **kwargs)).save() diff --git a/openedx/core/djangoapps/programs/tests/test_models.py b/openedx/core/djangoapps/programs/tests/test_models.py index 5cf09e57bf..da2182689c 100644 --- a/openedx/core/djangoapps/programs/tests/test_models.py +++ b/openedx/core/djangoapps/programs/tests/test_models.py @@ -2,34 +2,20 @@ Tests for models supporting Program-related functionality. """ -from django.test import TestCase from mock import patch +from django.test import TestCase + from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin @patch('config_models.models.cache.get', return_value=None) # during tests, make every cache get a miss. -class ProgramsApiConfigTest(TestCase): +class ProgramsApiConfigTest(ProgramsApiConfigMixin, TestCase): """ Tests for the ProgramsApiConfig model. """ - INTERNAL_URL = "http://internal/" - PUBLIC_URL = "http://public/" - - DEFAULTS = dict( - internal_service_url=INTERNAL_URL, - public_service_url=PUBLIC_URL, - api_version_number=1, - ) - - def create_config(self, **kwargs): - """ - DRY helper. Create a new ProgramsApiConfig with self.DEFAULTS, updated - with any kwarg overrides. - """ - ProgramsApiConfig(**dict(self.DEFAULTS, **kwargs)).save() - def test_default_state(self, _mock_cache): """ Ensure the config stores empty values when no data has been inserted, diff --git a/openedx/core/djangoapps/programs/tests/test_views.py b/openedx/core/djangoapps/programs/tests/test_views.py new file mode 100644 index 0000000000..f9aceeff81 --- /dev/null +++ b/openedx/core/djangoapps/programs/tests/test_views.py @@ -0,0 +1,208 @@ +""" +Tests for the Programs. +""" + +from mock import patch +from provider.oauth2.models import Client +from provider.constants import CONFIDENTIAL +from unittest import skipUnless + +from django.conf import settings +from django.test import TestCase + +from openedx.core.djangoapps.programs.views import get_course_programs_for_dashboard +from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin +from student.tests.factories import UserFactory + + +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestGetXSeriesPrograms(ProgramsApiConfigMixin, TestCase): + """ + Tests for the Programs views. + """ + + def setUp(self, **kwargs): # pylint: disable=unused-argument + super(TestGetXSeriesPrograms, self).setUp() + self.create_config(enabled=True, enable_student_dashboard=True) + Client.objects.get_or_create(name="programs", client_type=CONFIDENTIAL) + self.user = UserFactory() + self.programs_api_response = { + "results": [ + { + 'category': 'xseries', + 'status': 'active', + 'subtitle': 'Dummy program 1 for testing', + 'name': 'First Program', + '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'}, + ] + } + ] + }, + { + 'category': 'xseries', + 'status': 'active', + 'subtitle': 'Dummy program 2 for testing', + 'name': 'Second Program', + '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'}, + ] + } + ] + } + ] + } + + def test_get_course_programs_with_valid_user_and_courses(self): + """ Test that the method 'get_course_programs_for_dashboard' returns + only matching courses from the xseries programs in the expected format. + """ + # mock the request call + with patch('slumber.Resource.get') as mock_get: + mock_get.return_value = self.programs_api_response + + # first test with user having multiple courses in a single xseries + programs = get_course_programs_for_dashboard( + self.user, + ['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'valid/edX/Course'] + ) + expected_output = { + 'edX/DemoX_1/Run_1': { + 'category': 'xseries', + 'status': 'active', + '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': [ + {'sku': '', 'mode_slug': 'ABC_1', 'course_key': 'edX/DemoX_1/Run_1'}, + {'sku': '', 'mode_slug': 'ABC_2', 'course_key': 'edX/DemoX_2/Run_2'}, + ] + } + ], + 'subtitle': 'Dummy program 1 for testing', + 'name': 'First Program' + }, + 'edX/DemoX_2/Run_2': { + 'category': 'xseries', + 'status': 'active', + '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': [ + {'sku': '', 'mode_slug': 'ABC_1', 'course_key': 'edX/DemoX_1/Run_1'}, + {'sku': '', 'mode_slug': 'ABC_2', 'course_key': 'edX/DemoX_2/Run_2'}, + ] + } + ], + 'subtitle': 'Dummy program 1 for testing', + 'name': 'First Program' + }, + } + self.assertTrue(mock_get.called) + self.assertEqual(expected_output, programs) + self.assertEqual(sorted(programs.keys()), ['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2']) + + # now test with user having multiple courses across two different + # xseries + mock_get.reset_mock() + programs = get_course_programs_for_dashboard( + self.user, + ['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run', 'valid/edX/Course'] + ) + expected_output['edX/Program/Program_Run'] = { + 'category': 'xseries', + 'status': 'active', + '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': [ + {'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'}, + ] + } + ], + 'subtitle': 'Dummy program 2 for testing', + 'name': 'Second Program' + } + self.assertTrue(mock_get.called) + self.assertEqual(expected_output, programs) + self.assertEqual( + sorted(programs.keys()), + ['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run'] + ) + + def test_get_course_programs_with_api_client_exception(self): + """ Test that the method 'get_course_programs_for_dashboard' returns + empty dictionary in case of an exception coming from patching slumber + based client 'programs_api_client'. + """ + # mock the request call + with patch('edx_rest_api_client.client.EdxRestApiClient.__init__') as mock_init: + # test output in case of any exception + mock_init.side_effect = Exception('exc') + programs = get_course_programs_for_dashboard( + self.user, + ['edX/DemoX_1/Run_1', 'valid/edX/Course'] + ) + self.assertTrue(mock_init.called) + self.assertEqual(programs, {}) + + def test_get_course_programs_with_exception(self): + """ Test that the method 'get_course_programs_for_dashboard' returns + empty dictionary in case of exception while accessing programs service. + """ + # mock the request call + with patch('slumber.Resource.get') as mock_get: + # test output in case of any exception + mock_get.side_effect = Exception('exc') + programs = get_course_programs_for_dashboard( + self.user, + ['edX/DemoX_1/Run_1', 'valid/edX/Course'] + ) + self.assertTrue(mock_get.called) + self.assertEqual(programs, {}) + + def test_get_course_programs_with_non_existing_courses(self): + """ Test that the method 'get_course_programs_for_dashboard' returns + only those program courses which exists in the programs api response. + """ + # mock the request call + with patch('slumber.Resource.get') as mock_get: + mock_get.return_value = self.programs_api_response + self.assertEqual( + get_course_programs_for_dashboard(self.user, ['invalid/edX/Course']), {} + ) + self.assertTrue(mock_get.called) + + def test_get_course_programs_with_empty_response(self): + """ Test that the method 'get_course_programs_for_dashboard' returns + empty dict if programs rest api client returns empty response. + """ + # mock the request call + with patch('slumber.Resource.get') as mock_get: + mock_get.return_value = {} + self.assertEqual( + get_course_programs_for_dashboard(self.user, ['edX/DemoX/Run']), {} + ) + self.assertTrue(mock_get.called) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py new file mode 100644 index 0000000000..c3b852dcad --- /dev/null +++ b/openedx/core/djangoapps/programs/utils.py @@ -0,0 +1,22 @@ +""" +Helper methods for Programs. +""" +from edx_rest_api_client.client import EdxRestApiClient +from openedx.core.djangoapps.programs.models import ProgramsApiConfig + + +def is_student_dashboard_programs_enabled(): # pylint: disable=invalid-name + """ Returns a Boolean indicating whether LMS dashboard functionality + related to Programs should be enabled or not. + """ + return ProgramsApiConfig.current().is_student_dashboard_enabled + + +def programs_api_client(api_url, jwt_access_token): + """ Returns an Programs API client setup with authentication for the + specified user. + """ + return EdxRestApiClient( + api_url, + jwt=jwt_access_token + ) diff --git a/openedx/core/djangoapps/programs/views.py b/openedx/core/djangoapps/programs/views.py new file mode 100644 index 0000000000..d9b45c1674 --- /dev/null +++ b/openedx/core/djangoapps/programs/views.py @@ -0,0 +1,68 @@ +""" +Main views and method related to the Programs. +""" + +import logging + +from openedx.core.djangoapps.util.helpers import get_id_token +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.utils import programs_api_client, is_student_dashboard_programs_enabled + + +log = logging.getLogger(__name__) +# OAuth2 Client name for programs +CLIENT_NAME = "programs" + + +def get_course_programs_for_dashboard(user, course_keys): # pylint: disable=invalid-name + """ Return all programs related to a user. + + Given a user and an iterable of course keys, find all + the programs relevant to the user's dashboard and return them in a + dictionary keyed by the course_key. + + Arguments: + user (user object): Currently logged-in User for which we need to get + JWT ID-Token + course_keys (list): List of course keys in which user is enrolled + + Returns: + Dictionary response containing programs or None + """ + course_programs = {} + if not is_student_dashboard_programs_enabled(): + log.warning("Programs service for student dashboard is disabled.") + return course_programs + + # unicode-ify the course keys for efficient lookup + course_keys = map(unicode, course_keys) + + # get programs slumber-based client 'EdxRestApiClient' + try: + api_client = programs_api_client(ProgramsApiConfig.current().internal_api_url, get_id_token(user, CLIENT_NAME)) + except Exception: # pylint: disable=broad-except + log.exception('Failed to initialize the Programs API client.') + return course_programs + + # get programs from api client + try: + response = api_client.programs.get() + except Exception: # pylint: disable=broad-except + log.exception('Failed to retrieve programs from the Programs API.') + return course_programs + + programs = response.get('results', []) + if not programs: + log.warning("No programs found for the user '%s'.", user.id) + return course_programs + + # reindex the result from pgm -> course code -> course run + # 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 + + return course_programs diff --git a/openedx/core/djangoapps/util/helpers.py b/openedx/core/djangoapps/util/helpers.py new file mode 100644 index 0000000000..4b7c188f84 --- /dev/null +++ b/openedx/core/djangoapps/util/helpers.py @@ -0,0 +1,48 @@ +""" +Common helpers methods for django apps. +""" + +import logging + +from provider.oauth2.models import AccessToken, Client +from provider.utils import now + +from django.core.exceptions import ImproperlyConfigured + + +log = logging.getLogger(__name__) + + +def get_id_token(user, client_name): + """Generates a JWT ID-Token, using or creating user's OAuth access token. + + Arguments: + user (User Object): User for which we need to get JWT ID-Token + client_name (unicode): Name of the OAuth2 Client + + Returns: + String containing the signed JWT value or raise the exception + 'ImproperlyConfigured' + """ + # TODO: there's a circular import problem somewhere which is why we do the oidc import inside of this function. + import oauth2_provider.oidc as oidc + + try: + client = Client.objects.get(name=client_name) + except Client.DoesNotExist: + raise ImproperlyConfigured("OAuth2 Client with name '%s' is not present in the DB" % client_name) + + access_tokens = AccessToken.objects.filter( + client=client, + user__username=user.username, + expires__gt=now() + ).order_by('-expires') + + if access_tokens: + access_token = access_tokens[0] + else: + access_token = AccessToken.objects.create(client=client, user=user) + + id_token = oidc.id_token(access_token) + secret = id_token.access_token.client.client_secret + return id_token.encode(secret) diff --git a/openedx/core/djangoapps/util/tests/__init__.py b/openedx/core/djangoapps/util/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/util/tests/test_helpers.py b/openedx/core/djangoapps/util/tests/test_helpers.py new file mode 100644 index 0000000000..be06d1c87b --- /dev/null +++ b/openedx/core/djangoapps/util/tests/test_helpers.py @@ -0,0 +1,45 @@ +""" +Tests for the helper methods. +""" + +import jwt +from oauth2_provider.tests.factories import ClientFactory +from provider.oauth2.models import AccessToken, Client +from unittest import skipUnless + +from django.conf import settings +from django.test import TestCase + +from openedx.core.djangoapps.util.helpers import get_id_token +from student.tests.factories import UserFactory + + +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class GetIdTokenTest(TestCase): + """ + Tests for then helper method 'get_id_token'. + """ + def setUp(self): + self.client_name = "edx-dummy-client" + ClientFactory(name=self.client_name) + super(GetIdTokenTest, self).setUp() + self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") + self.client.login(username=self.user.username, password="edx") + + def test_get_id_token(self): + """ + Test generation of ID Token. + """ + # test that a user with no ID Token gets a valid token on calling the + # method 'get_id_token' against a client + self.assertEqual(AccessToken.objects.all().count(), 0) + client = Client.objects.get(name=self.client_name) + first_token = get_id_token(self.user, self.client_name) + self.assertEqual(AccessToken.objects.all().count(), 1) + jwt.decode(first_token, client.client_secret, audience=client.client_id) + + # test that a user with existing ID Token gets the same token instead + # of a new generated token + second_token = get_id_token(self.user, self.client_name) + self.assertEqual(AccessToken.objects.all().count(), 1) + self.assertEqual(first_token, second_token) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 527c35d635..3e086ce62d 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -30,7 +30,7 @@ django-storages==1.1.5 django-method-override==0.1.0 djangorestframework>=3.1,<3.2 django==1.4.22 -edx-rest-api-client==1.2.0 +edx-rest-api-client==1.2.1 elasticsearch==0.4.5 facebook-sdk==0.4.0 feedparser==5.1.3