Commit 7f96474d by zubair-arbi

basic programs api setup and dashboard integration

ECOM-2578
parent 9f89112e
......@@ -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)
......
"""
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,
......
"""
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
)
......
......@@ -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):
"""
......
......@@ -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
......
"""
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.
"""
"""
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()
......@@ -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,
......
"""
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)
"""
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
)
"""
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
"""
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)
"""
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)
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment