Commit 8f4c2264 by Jim Abramson

Merge pull request #10701 from edx/renzo/studio-programs-tab

Add Programs tab to Studio
parents 115ab3c1 70d57327
"""Tests covering the Programs listing on the Studio home."""
from django.core.urlresolvers import reverse
import httpretty
from oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, ModuleStoreTestCase):
"""Verify Program listing behavior."""
def setUp(self):
super(TestProgramListing, self).setUp()
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
self.user = UserFactory(is_staff=True)
self.client.login(username=self.user.username, password='test')
self.studio_home = reverse('home')
@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.mock_programs_api()
response = self.client.get(self.studio_home)
self.assertNotIn("You haven't created any programs yet.", response.content)
for program_name in self.PROGRAM_NAMES:
self.assertNotIn(program_name, response.content)
@httpretty.activate
def test_programs_requires_staff(self):
"""Verify that the programs tab and creation button aren't rendered unless the user has global staff."""
self.user = UserFactory(is_staff=False)
self.client.login(username=self.user.username, password='test')
self.create_config()
self.mock_programs_api()
response = self.client.get(self.studio_home)
self.assertNotIn("You haven't created any programs yet.", response.content)
@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': []})
response = self.client.get(self.studio_home)
self.assertIn("You haven't created any programs yet.", response.content)
# When data is provided, expect a program listing.
self.mock_programs_api()
response = self.client.get(self.studio_home)
for program_name in self.PROGRAM_NAMES:
self.assertIn(program_name, response.content)
......@@ -2,97 +2,96 @@
Views related to operations on course objects
"""
import copy
from django.shortcuts import redirect
import json
import random
import logging
import string
from django.utils.translation import ugettext as _
import django.utils
from django.contrib.auth.decorators import login_required
import random
import string # pylint: disable=deprecated-module
from django.conf import settings
from django.views.decorators.http import require_http_methods, require_GET
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404
from util.json_request import JsonResponse, JsonResponseBadRequest
from util.date_utils import get_default_time_display
from edxmako.shortcuts import render_to_response
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from xmodule.modulestore import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, Http404
from django.shortcuts import redirect
import django.utils
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_http_methods, require_GET
from django.views.decorators.csrf import ensure_csrf_cookie
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import Location
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import Location
from django.views.decorators.csrf import ensure_csrf_cookie
from openedx.core.lib.js_utils import escape_json_dumps
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
from .component import (
ADVANCED_COMPONENT_TYPES,
SPLIT_TEST_COMPONENT_TYPE,
)
from .item import create_xblock_info
from .library import LIBRARIES_ENABLED
from contentstore import utils
from contentstore.course_group_config import (
COHORT_SCHEME,
GroupConfiguration,
GroupConfigurationsValidationError,
RANDOM_SCHEME,
COHORT_SCHEME
)
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from contentstore.push_notification import push_notification_enabled
from contentstore.tasks import rerun_course
from contentstore.utils import (
add_instructor,
initialize_permissions,
get_lms_link_for_item,
remove_all_instructors,
reverse_course_url,
reverse_library_url,
reverse_usage_url,
reverse_url,
remove_all_instructors,
)
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata
from util.json_request import expect_json
from util.string_utils import _has_non_ascii_characters
from student.auth import has_studio_write_access, has_studio_read_access
from .component import (
SPLIT_TEST_COMPONENT_TYPE,
ADVANCED_COMPONENT_TYPES,
)
from contentstore.tasks import rerun_course
from contentstore.views.entrance_exam import (
create_entrance_exam,
delete_entrance_exam,
update_entrance_exam,
delete_entrance_exam
)
from .library import LIBRARIES_ENABLED
from .item import create_xblock_info
from contentstore.push_notification import push_notification_enabled
from course_action_state.managers import CourseActionStateItemNotFoundError
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
from contentstore import utils
from edxmako.shortcuts import render_to_response
from microsite_configuration import microsite
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.utils import get_programs
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.lib.course_tabs import CourseTabPluginManager
from openedx.core.lib.js_utils import escape_json_dumps
from student import auth
from student.auth import has_course_author_access, has_studio_write_access, has_studio_read_access
from student.roles import (
CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff, UserBasedRole
)
from student import auth
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from course_action_state.managers import CourseActionStateItemNotFoundError
from microsite_configuration import microsite
from xmodule.course_module import CourseFields
from student.auth import has_course_author_access
from util.date_utils import get_default_time_display
from util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from util.milestones_helpers import (
set_prerequisite_courses,
is_valid_course_key,
is_entrance_exams_enabled,
is_prerequisite_courses_enabled,
is_entrance_exams_enabled
is_valid_course_key,
set_prerequisite_courses,
)
from util.string_utils import _has_non_ascii_characters
from xmodule.contentstore.content import StaticContent
from xmodule.course_module import CourseFields
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import EdxJSONEncoder
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException
log = logging.getLogger(__name__)
......@@ -219,6 +218,7 @@ def _dismiss_notification(request, course_action_state_id): # pylint: disable=u
return JsonResponse({'success': True})
# pylint: disable=unused-argument
@login_required
def course_handler(request, course_key_string=None):
"""
......@@ -422,6 +422,13 @@ def course_listing(request):
courses, in_process_course_actions = get_courses_accessible_to_user(request)
libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
programs_config = ProgramsApiConfig.current()
raw_programs = get_programs(request.user) if programs_config.is_studio_tab_enabled else []
# Sort programs alphabetically by name.
# TODO: Support ordering in the Programs API itself.
programs = sorted(raw_programs, key=lambda p: p['name'].lower())
def format_in_process_course_view(uca):
"""
Return a dict of the data which the view requires for each unsucceeded course
......@@ -470,7 +477,9 @@ def course_listing(request):
'course_creator_status': _get_course_creator_status(request.user),
'rerun_creator_status': GlobalStaff().has_user(request.user),
'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False),
'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True)
'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True),
'is_programs_enabled': programs_config.is_studio_tab_enabled and request.user.is_staff,
'programs': programs,
})
......@@ -805,6 +814,7 @@ def _rerun_course(request, org, number, run, fields):
})
# pylint: disable=unused-argument
@login_required
@ensure_csrf_cookie
@require_http_methods(["GET"])
......@@ -837,6 +847,7 @@ def course_info_handler(request, course_key_string):
return HttpResponseBadRequest("Only supports html requests")
# pylint: disable=unused-argument
@login_required
@ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
......
......@@ -364,3 +364,8 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g
PROCTORING_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS)
############################ OAUTH2 Provider ###################################
# OpenID Connect issuer ID. Normally the URL of the authentication endpoint.
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
......@@ -103,5 +103,6 @@
"TECH_SUPPORT_EMAIL": "technical@example.com",
"THEME_NAME": "",
"TIME_ZONE": "America/New_York",
"WIKI_ENABLED": true
"WIKI_ENABLED": true,
"OAUTH_OIDC_ISSUER": "https://www.example.com/oauth2"
}
......@@ -806,6 +806,11 @@ INSTALLED_APPS = (
# Self-paced course configuration
'openedx.core.djangoapps.self_paced',
# OAuth2 Provider
'provider',
'provider.oauth2',
'oauth2_provider',
# These are apps that aren't strictly needed by Studio, but are imported by
# other apps that are. Django 1.8 wants to have imported models supported
# by installed apps.
......@@ -1112,3 +1117,9 @@ PROCTORING_BACKEND_PROVIDER = {
'options': {},
}
PROCTORING_SETTINGS = {}
############################ OAUTH2 Provider ###################################
# OpenID Connect issuer ID. Normally the URL of the authentication endpoint.
OAUTH_OIDC_ISSUER = 'https://www.example.com/oauth2'
......@@ -115,6 +115,9 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True
# Whether to run django-require in debug mode.
REQUIRE_DEBUG = DEBUG
########################### OAUTH2 #################################
OAUTH_OIDC_ISSUER = 'http://127.0.0.1:8000/oauth2'
###############################################################################
# See if the developer has any local overrides.
if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')):
......
......@@ -33,9 +33,6 @@ from lms.envs.test import (
DEFAULT_FILE_STORAGE,
MEDIA_ROOT,
MEDIA_URL,
# This is practically unused but needed by the oauth2_provider package, which
# some tests in common/ rely on.
OAUTH_OIDC_ISSUER,
)
# mongo connection settings
......
"""Studio tab plugin manager and API."""
import abc
from openedx.core.lib.api.plugins import PluginManager
class StudioTabPluginManager(PluginManager):
"""Manager for all available Studio tabs.
Examples of Studio tabs include Courses, Libraries, and Programs. All Studio
tabs should implement `StudioTab`.
"""
NAMESPACE = 'openedx.studio_tab'
@classmethod
def get_enabled_tabs(cls):
"""Returns a list of enabled Studio tabs."""
tabs = cls.get_available_plugins()
enabled_tabs = [tab for tab in tabs.viewvalues() if tab.is_enabled()]
return enabled_tabs
class StudioTab(object):
"""Abstract class used to represent Studio tabs.
Examples of Studio tabs include Courses, Libraries, and Programs.
"""
__metaclass__ = abc.ABCMeta
@abc.abstractproperty
def tab_text(self):
"""Text to display in a tab used to navigate to a list of instances of this tab.
Should be internationalized using `ugettext_noop()` since the user won't be available in this context.
"""
pass
@abc.abstractproperty
def button_text(self):
"""Text to display in a button used to create a new instance of this tab.
Should be internationalized using `ugettext_noop()` since the user won't be available in this context.
"""
pass
@abc.abstractproperty
def view_name(self):
"""Name of the view used to render this tab.
Used within templates in conjuction with Django's `reverse()` to generate a URL for this tab.
"""
pass
@abc.abstractmethod
def is_enabled(cls, user=None): # pylint: disable=no-self-argument,unused-argument
"""Indicates whether this tab should be enabled.
This is a class method; override with @classmethod.
Keyword Arguments:
user (User): The user signed in to Studio.
"""
pass
"""Tests for the Studio tab plugin API."""
from django.test import TestCase
import mock
from cms.lib.studio_tabs import StudioTabPluginManager
from openedx.core.lib.api.plugins import PluginError
class TestStudioTabPluginApi(TestCase):
"""Unit tests for the Studio tab plugin API."""
@mock.patch('cms.lib.studio_tabs.StudioTabPluginManager.get_available_plugins')
def test_get_enabled_tabs(self, get_available_plugins):
"""Verify that only enabled tabs are retrieved."""
enabled_tab = self._mock_tab(is_enabled=True)
mock_tabs = {
'disabled_tab': self._mock_tab(),
'enabled_tab': enabled_tab,
}
get_available_plugins.return_value = mock_tabs
self.assertEqual(StudioTabPluginManager.get_enabled_tabs(), [enabled_tab])
def test_get_invalid_plugin(self):
"""Verify that get_plugin fails when an invalid plugin is requested."""
with self.assertRaises(PluginError):
StudioTabPluginManager.get_plugin('invalid_tab')
def _mock_tab(self, is_enabled=False):
"""Generate a mock tab."""
tab = mock.Mock()
tab.is_enabled = mock.Mock(return_value=is_enabled)
return tab
......@@ -141,20 +141,26 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
e.preventDefault();
$('.courses-tab').toggleClass('active', tab === 'courses');
$('.libraries-tab').toggleClass('active', tab === 'libraries');
$('.programs-tab').toggleClass('active', tab === 'programs');
// Also toggle this course-related notice shown below the course tab, if it is present:
$('.wrapper-creationrights').toggleClass('is-hidden', tab === 'libraries');
$('.wrapper-creationrights').toggleClass('is-hidden', tab !== 'courses');
};
};
var onReady = function () {
$('.new-course-button').bind('click', addNewCourse);
$('.new-library-button').bind('click', addNewLibrary);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
ViewUtils.reload();
}));
$('.action-reload').bind('click', ViewUtils.reload);
$('#course-index-tabs .courses-tab').bind('click', showTab('courses'));
$('#course-index-tabs .libraries-tab').bind('click', showTab('libraries'));
$('#course-index-tabs .programs-tab').bind('click', showTab('programs'));
};
domReady(onReady);
......
......@@ -55,7 +55,7 @@
}
.action-create-course, .action-create-library {
.action-create-course, .action-create-library, .action-create-program {
@extend %btn-primary-green;
@extend %t-action3;
}
......@@ -318,7 +318,7 @@
}
// ELEM: course listings
.courses-tab, .libraries-tab {
.courses-tab, .libraries-tab, .programs-tab {
display: none;
&.active {
......@@ -326,7 +326,7 @@
}
}
.courses, .libraries {
.courses, .libraries, .programs {
.title {
@extend %t-title6;
margin-bottom: $baseline;
......
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "home" %></%def>
<%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</%block>
<%block name="bodyclass">is-signedin index view-dashboard</%block>
......@@ -27,10 +28,17 @@
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):
<a href="mailto:${settings.FEATURES.get('STUDIO_REQUEST_EMAIL','')}">${_("Email staff to create course")}</a>
% endif
% if show_new_library_button:
<a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i>
${_("New Library")}</a>
% endif
% if is_programs_enabled:
<!-- TODO: Link to the program creation view in the authoring app. -->
<button class="button new-button new-program-button"><i class="icon fa fa-plus icon-inline"></i>
${_("New Program")}</button>
% endif
</li>
</ul>
</nav>
......@@ -271,12 +279,19 @@
</div>
%endif
%if libraries_enabled:
% if libraries_enabled or is_programs_enabled:
<ul id="course-index-tabs">
<li class="courses-tab active"><a>${_("Courses")}</a></li>
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
% if libraries_enabled:
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
% endif
% if is_programs_enabled:
<li class="programs-tab"><a>${_("Programs")}</a></li>
% endif
</ul>
%endif
% endif
%if len(courses) > 0:
<div class="courses courses-tab active">
......@@ -485,6 +500,54 @@
</div>
%endif
% if is_programs_enabled:
% if len(programs) > 0:
<div class="programs programs-tab">
<!-- Classes related to courses are intentionally reused here, to duplicate the styling used for course listing. -->
<ul class="list-courses">
% for program in programs:
<li class="course-item">
<!-- TODO: Use the program ID contained in the dict to link to the appropriate view in the authoring app. -->
<a class="program-link" href="#">
<h3 class="course-title">${program['name'] | h}</h3>
<div class="course-metadata">
<span class="course-org metadata-item">
<!-- As of this writing, programs can only be owned by one organization. If that constraint is relaxed, this will need to be revisited. -->
<span class="label">${_("Organization:")}</span> <span class="value">${program['organizations'][0]['key'] | h}</span>
</span>
</div>
</a>
</li>
% endfor
</ul>
</div>
% else:
<div class="notice notice-incontext notice-instruction notice-instruction-nocourses list-notices programs-tab">
<div class="notice-item has-actions">
<div class="msg">
<h3 class="title">${_("You haven't created any programs yet.")}</h3>
<div class="copy">
<p>${_("Programs are groups of courses related to a common subject.")}</p>
</div>
</div>
<ul class="list-actions">
<li class="action-item">
<!-- TODO: Link to the program creation view in the authoring app. -->
<button class="action-primary action-create new-button action-create-program new-program-button"><i class="icon fa fa-plus icon-inline"></i> ${_('Create Your First Program')}</button>
</li>
</ul>
</div>
</div>
% endif
% endif
</article>
<aside class="content-supplementary" role="complementary">
<div class="bit">
......
......@@ -955,7 +955,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
@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:
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = {
u'edx/demox/Run_1': {
'category': self.category,
......@@ -997,15 +997,10 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
"""
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')]
)
with patch('student.views.get_programs_for_dashboard') as mock_method:
mock_method.return_value = self._create_program_data([])
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)
......@@ -1020,9 +1015,9 @@ 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(enabled=True, enable_student_dashboard=True)
self.create_config()
with patch('student.views.get_course_programs_for_dashboard') as mock_data:
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = self._create_program_data(
[(self.course_1.id, 'active'), (self.course_2.id, 'unpublished')]
)
......@@ -1053,10 +1048,10 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
self.client.login(username="jack", password="test")
self.create_config(enabled=True, enable_student_dashboard=True)
self.create_config()
with patch(
'student.views.get_course_programs_for_dashboard',
'student.views.get_programs_for_dashboard',
return_value=self._create_program_data([(self.course_1.id, 'active')])
) as mock_get_programs:
response = self.client.get(reverse('dashboard'))
......@@ -1083,9 +1078,9 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
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)
self.create_config()
with patch('student.views.get_course_programs_for_dashboard') as mock_data:
with patch('student.views.get_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),
......@@ -1104,13 +1099,13 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test")
self.create_config(enabled=True, enable_student_dashboard=True)
self.create_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)]:
del program_data[unicode(self.course_1.id)][key_remove]
with patch('student.views.get_course_programs_for_dashboard') as mock_data:
with patch('student.views.get_programs_for_dashboard') as mock_data:
mock_data.return_value = program_data
response = self.client.get(reverse('dashboard'))
......
......@@ -125,13 +125,12 @@ 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
from openedx.core.djangoapps.programs.utils import get_programs_for_dashboard
log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit")
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display')
ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
......@@ -583,12 +582,10 @@ 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(user, [enrollment.course_id for enrollment in course_enrollments])
# Get any programs associated with courses being displayed.
# 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])
# Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict
......@@ -1632,7 +1629,7 @@ def create_account_with_params(request, params):
if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
tracking_context = tracker.get_tracker().resolve_context()
identity_args = [
user.id,
user.id, # pylint: disable=no-member
{
'email': user.email,
'username': user.username,
......@@ -1895,13 +1892,13 @@ def auto_auth(request):
'username': username,
'email': email,
'password': password,
'user_id': user.id,
'user_id': user.id, # pylint: disable=no-member
'anonymous_id': anonymous_id_for_user(user, None),
})
else:
success_msg = u"{} user {} ({}) with password {} and user_id {}".format(
u"Logged in" if login_when_done else "Created",
username, email, password, user.id
username, email, password, user.id # pylint: disable=no-member
)
response = HttpResponse(success_msg)
response.set_cookie('csrftoken', csrf(request)['csrf_token'])
......@@ -2285,24 +2282,23 @@ def change_email_settings(request):
return JsonResponse({"success": True})
def _get_course_programs(user, user_enrolled_courses):
""" Returns a dictionary of programs courses data require for the student
dashboard.
def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invalid-name
"""Build a dictionary of program data required for display on 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.
Given a user and an iterable of course keys, find all programs relevant to the
user and return them in a dictionary keyed by course key.
Arguments:
user (user object): Currently logged-in User
user_enrolled_courses (list): List of course keys in which user is
enrolled
user (User): The user to authenticate as when requesting programs.
user_enrolled_courses (list): List of course keys representing the courses in which
the given user has active enrollments.
Returns:
Dictionary response containing programs or {}
dict, containing programs keyed by course. Empty if programs cannot be retrieved.
"""
course_programs = get_course_programs_for_dashboard(user, user_enrolled_courses)
course_programs = get_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:
......
"""
Stub implementation of programs service for acceptance tests
"""
import re
import urlparse
from .http import StubHttpRequestHandler, StubHttpService
class StubProgramsServiceHandler(StubHttpRequestHandler): # pylint: disable=missing-docstring
def do_GET(self): # pylint: disable=invalid-name, missing-docstring
pattern_handlers = {
"/api/v1/programs/$": self.get_programs_list,
}
if self.match_pattern(pattern_handlers):
return
self.send_response(404, content="404 Not Found")
def match_pattern(self, pattern_handlers):
"""
Find the correct handler method given the path info from the HTTP request.
"""
path = urlparse.urlparse(self.path).path
for pattern in pattern_handlers:
match = re.match(pattern, path)
if match:
pattern_handlers[pattern](**match.groupdict())
return True
return None
def get_programs_list(self):
"""
Stubs the programs list endpoint.
"""
programs = self.server.config.get('programs', [])
self.send_json_response(programs)
class StubProgramsService(StubHttpService): # pylint: disable=missing-docstring
HANDLER_CLASS = StubProgramsServiceHandler
......@@ -11,6 +11,7 @@ from .ora import StubOraService
from .lti import StubLtiService
from .video_source import VideoSourceHttpService
from .edxnotes import StubEdxNotesService
from .programs import StubProgramsService
USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]"
......@@ -23,6 +24,7 @@ SERVICES = {
'lti': StubLtiService,
'video': VideoSourceHttpService,
'edxnotes': StubEdxNotesService,
'programs': StubProgramsService,
}
# Log to stdout, including debug messages
......
......@@ -17,3 +17,6 @@ COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567')
# Get the URL of the EdxNotes service stub used in the test
EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042')
# Get the URL of the EdxNotes service stub used in the test
PROGRAMS_STUB_URL = os.environ.get('programs_url', 'http://localhost:8090')
"""
Tools to create programs-related data for use in bok choy tests.
"""
import json
import factory
import requests
from . import PROGRAMS_STUB_URL
class Program(factory.Factory):
"""
Factory for stubbing program resources from the Programs API (v1).
"""
class Meta(object):
model = dict
id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name
name = "dummy-program-name"
subtitle = "dummy-program-subtitle"
category = "xseries"
status = "unpublished"
organizations = []
course_codes = []
class Organization(factory.Factory):
"""
Factory for stubbing nested organization resources from the Programs API (v1).
"""
class Meta(object):
model = dict
key = "dummyX"
display_name = "dummy-org-display-name"
class ProgramsFixture(object):
"""
Interface to set up mock responses from the Programs stub server.
"""
def install_programs(self, program_values):
"""
Sets the response data for the programs list endpoint.
At present, `program_values` needs to be a sequence of sequences of (program_name, org_key).
"""
programs = []
for program_name, org_key in program_values:
org = Organization(key=org_key)
program = Program(name=program_name, organizations=[org])
programs.append(program)
api_result = {'results': programs}
requests.put(
'{}/set_config'.format(PROGRAMS_STUB_URL),
data={'programs': json.dumps(api_result)},
)
......@@ -128,3 +128,49 @@ class DashboardPage(PageObject):
if all([lib[key] == kwargs[key] for key in kwargs]):
return True
return False
class DashboardPageWithPrograms(DashboardPage):
"""
Extends DashboardPage for bok choy testing programs-related behavior.
"""
def is_programs_tab_present(self):
"""
Determine if the programs tab appears on the studio home page.
"""
return self.q(css='#course-index-tabs .programs-tab a').present
def _click_programs_tab(self):
"""
DRY helper.
"""
self.q(css='#course-index-tabs .programs-tab a').click()
self.wait_for_element_visibility("div.programs-tab.active", "Switch to programs tab")
def is_new_program_button_present(self):
"""
Determine if the "new program" button is visible in the top "nav
actions" section of the page.
"""
return self.q(css='.nav-actions button.new-program-button').present
def is_empty_list_create_button_present(self):
"""
Determine if the "create your first program" button is visible under
the programs tab (when the program list result is empty).
"""
self._click_programs_tab()
return self.q(css='div.programs-tab.active button.new-program-button').present
def get_program_list(self):
"""
Fetch the content of the program list under the programs tab (assuming
it is nonempty).
"""
self._click_programs_tab()
div2info = lambda element: (
element.find_element_by_css_selector('.course-title').text, # name
element.find_element_by_css_selector('.course-org .value').text, # org key
)
return self.q(css='div.programs-tab li.course-item').map(div2info).results
......@@ -4,9 +4,12 @@ Acceptance tests for Home Page (My Courses / My Libraries).
from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import LibraryLocator
from ...fixtures import PROGRAMS_STUB_URL
from ...fixtures.config import ConfigModelFixture
from ...fixtures.programs import ProgramsFixture
from ...pages.studio.auto_auth import AutoAuthPage
from ...pages.studio.library import LibraryEditPage
from ...pages.studio.index import DashboardPage
from ...pages.studio.index import DashboardPage, DashboardPageWithPrograms
class CreateLibraryTest(WebAppTest):
......@@ -55,3 +58,84 @@ class CreateLibraryTest(WebAppTest):
# Then go back to the home page and make sure the new library is listed there:
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
class DashboardProgramsTabTest(WebAppTest):
"""
Test the programs tab on the studio home page.
"""
def setUp(self):
super(DashboardProgramsTabTest, self).setUp()
ProgramsFixture().install_programs([])
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPageWithPrograms(self.browser)
self.auth_page.visit()
def set_programs_api_configuration(self, is_enabled=False, api_version=1, api_url=PROGRAMS_STUB_URL,
js_path='/js', css_path='/css'):
"""
Dynamically adjusts the programs API config model during tests.
"""
ConfigModelFixture('/config/programs', {
'enabled': is_enabled,
'enable_studio_tab': is_enabled,
'enable_student_dashboard': is_enabled,
'api_version_number': api_version,
'internal_service_url': api_url,
'public_service_url': api_url,
'authoring_app_js_path': js_path,
'authoring_app_css_path': css_path,
'cache_ttl': 0
}).install()
def test_tab_is_disabled(self):
"""
The programs tab and "new program" button should not appear at all
unless enabled via the config model.
"""
self.set_programs_api_configuration()
self.dashboard_page.visit()
self.assertFalse(self.dashboard_page.is_programs_tab_present())
self.assertFalse(self.dashboard_page.is_new_program_button_present())
def test_tab_is_enabled_with_empty_list(self):
"""
The programs tab and "new program" button should appear when enabled
via config. When the programs list is empty, a button should appear
that allows creating a new program.
"""
self.set_programs_api_configuration(True)
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.is_programs_tab_present())
self.assertTrue(self.dashboard_page.is_new_program_button_present())
results = self.dashboard_page.get_program_list()
self.assertEqual(results, [])
self.assertTrue(self.dashboard_page.is_empty_list_create_button_present())
def test_tab_is_enabled_with_nonempty_list(self):
"""
The programs tab and "new program" button should appear when enabled
via config, and the results of the program list should display when
the list is nonempty.
"""
test_program_values = [('first program', 'org1'), ('second program', 'org2')]
ProgramsFixture().install_programs(test_program_values)
self.set_programs_api_configuration(True)
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.is_programs_tab_present())
self.assertTrue(self.dashboard_page.is_new_program_button_present())
results = self.dashboard_page.get_program_list()
self.assertEqual(results, test_program_values)
self.assertFalse(self.dashboard_page.is_empty_list_create_button_present())
def test_tab_requires_staff(self):
"""
The programs tab and "new program" button will not be available, even
when enabled via config, if the user is not global staff.
"""
self.set_programs_api_configuration(True)
AutoAuthPage(self.browser, staff=False).visit()
self.dashboard_page.visit()
self.assertFalse(self.dashboard_page.is_programs_tab_present())
self.assertFalse(self.dashboard_page.is_new_program_button_present())
[
{
"pk": 2,
"model": "oauth2.client",
"fields": {
"name": "programs",
"url": "http://example.com/",
"client_type": 1,
"redirect_uri": "http://example.com/welcome",
"user": null,
"client_id": "programs-client-id",
"client_secret": "programs-client-secret"
}
}
]
......@@ -22,7 +22,7 @@ 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 openedx.core.lib.token_utils 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
......
......@@ -13,6 +13,7 @@ from microsite_configuration import microsite
import auth_exchange.views
from config_models.views import ConfigurationModelCurrentAPIView
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
# Uncomment the next two lines to enable the admin:
......@@ -738,6 +739,7 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"):
urlpatterns += (
url(r'config/self_paced', ConfigurationModelCurrentAPIView.as_view(model=SelfPacedConfiguration)),
url(r'config/programs', ConfigurationModelCurrentAPIView.as_view(model=ProgramsApiConfig)),
)
urlpatterns = patterns(*urlpatterns)
......
Open edX
--------
This is the root package for Open edX.
The intent is that all importable code from Open edX will eventually live here,
including the code in the lms, cms, and common directories.
This is the root package for Open edX. The intent is that all importable code
from Open edX will eventually live here, including the code in the lms, cms,
and common directories.
Note: for now the code is not structured like this, and hence legacy code will
continue to live in a number of different packages. All new code should be
created in this package, and the legacy code will be moved here gradually.
If you're adding a new Django app, place it in core/djangoapps. If you're adding
code that defines no Django models or views of its own but is widely useful, put it
in core/lib.
Note: All new code should be created in this package, and the legacy code will
be moved here gradually. For now the code is not structured like this, and hence
legacy code will continue to live in a number of different packages.
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('programs', '0002_programsapiconfig_cache_ttl'),
]
operations = [
migrations.AddField(
model_name='programsapiconfig',
name='authoring_app_css_path',
field=models.CharField(max_length=255, verbose_name="Path to authoring app's CSS", blank=True),
),
migrations.AddField(
model_name='programsapiconfig',
name='authoring_app_js_path',
field=models.CharField(max_length=255, verbose_name="Path to authoring app's JS", blank=True),
),
migrations.AddField(
model_name='programsapiconfig',
name='enable_studio_tab',
field=models.BooleanField(default=False, verbose_name='Enable Studio Authoring Interface'),
),
migrations.AlterField(
model_name='programsapiconfig',
name='enable_student_dashboard',
field=models.BooleanField(default=False, verbose_name='Enable Student Dashboard Displays'),
),
]
"""
Models providing Programs support for the LMS and Studio.
"""
"""Models providing Programs support for the LMS and Studio."""
from collections import namedtuple
from urlparse import urljoin
from django.db.models import NullBooleanField, IntegerField, URLField
from django.utils.translation import ugettext_lazy as _
from django.db import models
from config_models.models import ConfigurationModel
AuthoringAppConfig = namedtuple('AuthoringAppConfig', ['js_url', 'css_url'])
class ProgramsApiConfig(ConfigurationModel):
"""
Manages configuration for connecting to the Programs service and using its
API.
"""
OAUTH2_CLIENT_NAME = 'programs'
CACHE_KEY = 'programs.api.data'
api_version_number = models.IntegerField(verbose_name=_("API Version"))
internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
public_service_url = models.URLField(verbose_name=_("Public Service URL"))
authoring_app_js_path = models.CharField(
verbose_name=_("Path to authoring app's JS"),
max_length=255,
blank=True,
help_text=_(
"This value is required in order to enable the Studio authoring interface."
)
)
authoring_app_css_path = models.CharField(
verbose_name=_("Path to authoring app's CSS"),
max_length=255,
blank=True,
help_text=_(
"This value is required in order to enable the Studio authoring interface."
)
)
internal_service_url = URLField(verbose_name=_("Internal Service URL"))
public_service_url = URLField(verbose_name=_("Public Service URL"))
api_version_number = IntegerField(verbose_name=_("API Version"))
enable_student_dashboard = NullBooleanField(verbose_name=_("Enable Student Dashboard Displays"))
cache_ttl = models.PositiveIntegerField(
verbose_name=_("Cache Time To Live"),
default=0,
......@@ -29,31 +49,62 @@ class ProgramsApiConfig(ConfigurationModel):
)
)
PROGRAMS_API_CACHE_KEY = "programs.api.data"
enable_student_dashboard = models.BooleanField(
verbose_name=_("Enable Student Dashboard Displays"),
default=False
)
enable_studio_tab = models.BooleanField(
verbose_name=_("Enable Studio Authoring Interface"),
default=False
)
@property
def internal_api_url(self):
"""
Generate a URL based on internal service URL and api version number.
Generate a URL based on internal service URL and API version number.
"""
return urljoin(self.internal_service_url, "/api/v{}/".format(self.api_version_number))
return urljoin(self.internal_service_url, '/api/v{}/'.format(self.api_version_number))
@property
def public_api_url(self):
"""
Generate a URL based on public service URL and api version number.
Generate a URL based on public service URL and API version number.
"""
return urljoin(self.public_service_url, "/api/v{}/".format(self.api_version_number))
return urljoin(self.public_service_url, '/api/v{}/'.format(self.api_version_number))
@property
def authoring_app_config(self):
"""
Returns a named tuple containing information required for working with the Programs
authoring app, a Backbone app hosted by the Programs service.
"""
js_url = urljoin(self.public_service_url, self.authoring_app_js_path)
css_url = urljoin(self.public_service_url, self.authoring_app_css_path)
return AuthoringAppConfig(js_url=js_url, css_url=css_url)
@property
def is_cache_enabled(self):
"""Whether responses from the Programs API will be cached."""
return self.cache_ttl > 0
@property
def is_student_dashboard_enabled(self):
"""
Indicate whether LMS dashboard functionality related to Programs should
Indicates whether LMS dashboard functionality related to Programs should
be enabled or not.
"""
return self.enabled and self.enable_student_dashboard
@property
def is_cache_enabled(self):
"""Whether responses from the Programs API will be cached."""
return self.enabled and self.cache_ttl > 0
def is_studio_tab_enabled(self):
"""
Indicates whether Studio functionality related to Programs should
be enabled or not.
"""
return (
self.enabled and
self.enable_studio_tab and
bool(self.authoring_app_js_path) and
bool(self.authoring_app_css_path)
)
"""
Broadly-useful mixins for use in automated tests.
"""
"""Mixins for use during testing."""
import json
import httpretty
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/"
"""Utilities for working with Programs configuration during testing."""
DEFAULTS = dict(
internal_service_url=INTERNAL_URL,
public_service_url=PUBLIC_URL,
api_version_number=1,
)
DEFAULTS = {
'enabled': True,
'api_version_number': 1,
'internal_service_url': 'http://internal.programs.org/',
'public_service_url': 'http://public.programs.org/',
'authoring_app_js_path': '/path/to/js',
'authoring_app_css_path': '/path/to/css',
'cache_ttl': 0,
'enable_student_dashboard': True,
'enable_studio_tab': True,
}
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()
"""Creates a new ProgramsApiConfig with DEFAULTS, updated with any provided overrides."""
fields = dict(self.DEFAULTS, **kwargs)
ProgramsApiConfig(**fields).save()
return ProgramsApiConfig.current()
class ProgramsDataMixin(object):
"""Mixin mocking Programs API URLs and providing fake data for testing."""
PROGRAM_NAMES = [
'Test Program A',
'Test Program B',
]
COURSE_KEYS = [
'organization-a/course-a/fall',
'organization-a/course-a/winter',
'organization-a/course-b/fall',
'organization-a/course-b/winter',
'organization-b/course-c/fall',
'organization-b/course-c/winter',
'organization-b/course-d/fall',
'organization-b/course-d/winter',
]
PROGRAMS_API_RESPONSE = {
'results': [
{
'id': 1,
'name': PROGRAM_NAMES[0],
'subtitle': 'A program used for testing purposes',
'category': 'xseries',
'status': 'unpublished',
'marketing_slug': '',
'organizations': [
{
'display_name': 'Test Organization A',
'key': 'organization-a'
}
],
'course_codes': [
{
'display_name': 'Test Course A',
'key': 'course-a',
'organization': {
'display_name': 'Test Organization A',
'key': 'organization-a'
},
'run_modes': [
{
'course_key': COURSE_KEYS[0],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'fall'
},
{
'course_key': COURSE_KEYS[1],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'winter'
}
]
},
{
'display_name': 'Test Course B',
'key': 'course-b',
'organization': {
'display_name': 'Test Organization A',
'key': 'organization-a'
},
'run_modes': [
{
'course_key': COURSE_KEYS[2],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'fall'
},
{
'course_key': COURSE_KEYS[3],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'winter'
}
]
}
],
'created': '2015-10-26T17:52:32.861000Z',
'modified': '2015-11-18T22:21:30.826365Z'
},
{
'id': 2,
'name': PROGRAM_NAMES[1],
'subtitle': 'Another program used for testing purposes',
'category': 'xseries',
'status': 'unpublished',
'marketing_slug': '',
'organizations': [
{
'display_name': 'Test Organization B',
'key': 'organization-b'
}
],
'course_codes': [
{
'display_name': 'Test Course C',
'key': 'course-c',
'organization': {
'display_name': 'Test Organization B',
'key': 'organization-b'
},
'run_modes': [
{
'course_key': COURSE_KEYS[4],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'fall'
},
{
'course_key': COURSE_KEYS[5],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'winter'
}
]
},
{
'display_name': 'Test Course D',
'key': 'course-d',
'organization': {
'display_name': 'Test Organization B',
'key': 'organization-b'
},
'run_modes': [
{
'course_key': COURSE_KEYS[6],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'fall'
},
{
'course_key': COURSE_KEYS[7],
'mode_slug': 'verified',
'sku': '',
'start_date': '2015-11-05T07:39:02.791741Z',
'run_key': 'winter'
}
]
}
],
'created': '2015-10-26T19:59:03.064000Z',
'modified': '2015-10-26T19:59:18.536000Z'
}
]
}
def mock_programs_api(self, data=None, status_code=200):
"""Utility for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
if data is None:
data = self.PROGRAMS_API_RESPONSE
body = json.dumps(data)
httpretty.reset()
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json', status=status_code)
"""
Tests for models supporting Program-related functionality.
"""
"""Tests for models supporting Program-related functionality."""
import ddt
from mock import patch
from django.test import TestCase
import mock
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
@ddt.ddt
@patch('config_models.models.cache.get', return_value=None) # during tests, make every cache get a miss.
class ProgramsApiConfigTest(ProgramsApiConfigMixin, TestCase):
"""
Tests for the ProgramsApiConfig model.
"""
# ConfigurationModels use the cache. Make every cache get a miss.
@mock.patch('config_models.models.cache.get', return_value=None)
class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
"""Tests covering the ProgramsApiConfig model."""
def test_url_construction(self, _mock_cache):
"""Verify that URLs returned by the model are constructed correctly."""
programs_config = self.create_config()
def test_default_state(self, _mock_cache):
"""
Ensure the config stores empty values when no data has been inserted,
and is completely disabled.
"""
self.assertFalse(ProgramsApiConfig.is_enabled())
api_config = ProgramsApiConfig.current()
self.assertEqual(api_config.internal_service_url, '')
self.assertEqual(api_config.public_service_url, '')
self.assertEqual(api_config.api_version_number, None)
self.assertFalse(api_config.is_student_dashboard_enabled)
self.assertEqual(
programs_config.internal_api_url,
programs_config.internal_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
)
self.assertEqual(
programs_config.public_api_url,
programs_config.public_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
)
def test_created_state(self, _mock_cache):
"""
Ensure the config stores correct values when created with them, but
remains disabled.
"""
self.create_config()
self.assertFalse(ProgramsApiConfig.is_enabled())
api_config = ProgramsApiConfig.current()
self.assertEqual(api_config.internal_service_url, self.INTERNAL_URL)
self.assertEqual(api_config.public_service_url, self.PUBLIC_URL)
self.assertEqual(api_config.api_version_number, 1)
self.assertFalse(api_config.is_student_dashboard_enabled)
authoring_app_config = programs_config.authoring_app_config
def test_api_urls(self, _mock_cache):
"""
Ensure the api url methods return correct concatenations of service
URLs and version numbers.
"""
self.create_config()
api_config = ProgramsApiConfig.current()
self.assertEqual(api_config.internal_api_url, "{}api/v1/".format(self.INTERNAL_URL))
self.assertEqual(api_config.public_api_url, "{}api/v1/".format(self.PUBLIC_URL))
self.assertEqual(
authoring_app_config.js_url,
programs_config.public_service_url.strip('/') + programs_config.authoring_app_js_path
)
self.assertEqual(
authoring_app_config.css_url,
programs_config.public_service_url.strip('/') + programs_config.authoring_app_css_path
)
@ddt.data(
(0, False),
(1, True),
)
@ddt.unpack
def test_cache_control(self, cache_ttl, is_cache_enabled, _mock_cache):
"""Verify the behavior of the property controlling whether API responses are cached."""
programs_config = self.create_config(cache_ttl=cache_ttl)
self.assertEqual(programs_config.is_cache_enabled, is_cache_enabled)
def test_is_student_dashboard_enabled(self, _mock_cache):
"""
Ensure that is_student_dashboard_enabled only returns True when the
current config has both 'enabled' and 'enable_student_dashboard' set to
True.
Verify that the property controlling display on the student dashboard is only True
when configuration is enabled and all required configuration is provided.
"""
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
self.create_config()
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
programs_config = self.create_config(enabled=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
self.create_config(enabled=True)
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
programs_config = self.create_config(enable_student_dashboard=False)
self.assertFalse(programs_config.is_student_dashboard_enabled)
self.create_config(enable_student_dashboard=True)
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
programs_config = self.create_config()
self.assertTrue(programs_config.is_student_dashboard_enabled)
self.create_config(enabled=True, enable_student_dashboard=True)
self.assertTrue(ProgramsApiConfig.current().is_student_dashboard_enabled)
@ddt.data(
(True, 0),
(False, 0),
(False, 1),
)
@ddt.unpack
def test_is_cache_enabled_returns_false(self, enabled, cache_ttl, _mock_cache):
"""Verify that the method 'is_cache_enabled' returns false if
'cache_ttl' value is 0 or config is not enabled.
def test_is_studio_tab_enabled(self, _mock_cache):
"""
Verify that the property controlling display of the Studio tab is only True
when configuration is enabled and all required configuration is provided.
"""
self.assertFalse(ProgramsApiConfig.current().is_cache_enabled)
programs_config = self.create_config(enabled=False)
self.assertFalse(programs_config.is_studio_tab_enabled)
self.create_config(
enabled=enabled,
cache_ttl=cache_ttl
)
self.assertFalse(ProgramsApiConfig.current().is_cache_enabled)
programs_config = self.create_config(enable_studio_tab=False)
self.assertFalse(programs_config.is_studio_tab_enabled)
def test_is_cache_enabled_returns_true(self, _mock_cache):
""""Verify that is_cache_enabled returns True when Programs is enabled
and the cache TTL is greater than 0."
"""
self.create_config(enabled=True, cache_ttl=10)
self.assertTrue(ProgramsApiConfig.current().is_cache_enabled)
programs_config = self.create_config(authoring_app_js_path='', authoring_app_css_path='')
self.assertFalse(programs_config.is_studio_tab_enabled)
programs_config = self.create_config()
self.assertTrue(programs_config.is_studio_tab_enabled)
"""Tests covering Programs utilities."""
from django.core.cache import cache
from django.test import TestCase
import httpretty
import mock
from oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
from openedx.core.djangoapps.programs.utils import get_programs, get_programs_for_dashboard
from student.tests.factories import UserFactory
class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, TestCase):
"""Tests covering the retrieval of programs from the Programs service."""
def setUp(self):
super(TestProgramRetrieval, self).setUp()
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
self.user = UserFactory()
cache.clear()
@httpretty.activate
def test_get_programs(self):
"""Verify programs data can be retrieved."""
self.create_config()
self.mock_programs_api()
actual = get_programs(self.user)
self.assertEqual(
actual,
self.PROGRAMS_API_RESPONSE['results']
)
# Verify the API was actually hit (not the cache).
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
@httpretty.activate
def test_get_programs_caching(self):
"""Verify that when enabled, the cache is used for non-staff users."""
self.create_config(cache_ttl=1)
self.mock_programs_api()
# Warm up the cache.
get_programs(self.user)
# Hit the cache.
get_programs(self.user)
# Verify only one request was made.
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
staff_user = UserFactory(is_staff=True)
# Hit the Programs API twice.
for _ in range(2):
get_programs(staff_user)
# Verify that three requests have been made (one for student, two for staff).
self.assertEqual(len(httpretty.httpretty.latest_requests), 3)
def test_get_programs_programs_disabled(self):
"""Verify behavior when programs is disabled."""
self.create_config(enabled=False)
actual = get_programs(self.user)
self.assertEqual(actual, [])
@mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
def test_get_programs_client_initialization_failure(self, mock_init):
"""Verify behavior when API client fails to initialize."""
self.create_config()
mock_init.side_effect = Exception
actual = get_programs(self.user)
self.assertEqual(actual, [])
self.assertTrue(mock_init.called)
@httpretty.activate
def test_get_programs_data_retrieval_failure(self):
"""Verify behavior when data can't be retrieved from Programs."""
self.create_config()
self.mock_programs_api(status_code=500)
actual = get_programs(self.user)
self.assertEqual(actual, [])
@httpretty.activate
def test_get_programs_for_dashboard(self):
"""Verify programs data can be retrieved and parsed correctly."""
self.create_config()
self.mock_programs_api()
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
expected = {}
for program in self.PROGRAMS_API_RESPONSE['results']:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
course_key = run['course_key']
expected[course_key] = program
self.assertEqual(actual, expected)
def test_get_programs_for_dashboard_dashboard_display_disabled(self):
"""Verify behavior when student dashboard display is disabled."""
self.create_config(enable_student_dashboard=False)
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
@httpretty.activate
def test_get_programs_for_dashboard_no_data(self):
"""Verify behavior when no programs data is found for the user."""
self.create_config()
self.mock_programs_api(data={'results': []})
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
@httpretty.activate
def test_get_programs_for_dashboard_invalid_data(self):
"""Verify behavior when the Programs API returns invalid data and parsing fails."""
self.create_config()
invalid_program = {'invalid_key': 'invalid_data'}
self.mock_programs_api(data={'results': [invalid_program]})
actual = get_programs_for_dashboard(self.user, self.COURSE_KEYS)
self.assertEqual(actual, {})
"""
Tests for the Programs.
"""
from unittest import skipUnless
import ddt
from mock import patch
from provider.oauth2.models import Client
from provider.constants import CONFIDENTIAL
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
# Explicitly import the cache from ConfigurationModel so we can reset it after each test
from config_models.models import cache
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
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()
cache.clear()
self.programs_api_response = {
"results": [
{
'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_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',
'run_modes': [
{'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'},
]
}
],
'marketing_slug': 'fake-marketing-slug-xseries-2',
}
]
}
self.expected_output = {
'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'},
'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'},
]
}
],
'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'},
'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'},
]
}
],
'organization': {'display_name': 'Test Organization 1', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-1',
},
}
self.edx_prg_run = {
'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',
'run_modes': [
{'sku': '', 'mode_slug': 'XYZ_1', 'course_key': 'edX/Program/Program_Run'},
]
}
],
'organization': {'display_name': 'Test Organization 2', 'key': 'edX'},
'marketing_slug': 'fake-marketing-slug-xseries-2',
}
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']
)
self.assertTrue(mock_get.called)
self.assertEqual(self.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']
)
self.expected_output['edX/Program/Program_Run'] = self.edx_prg_run
self.assertTrue(mock_get.called)
self.assertEqual(self.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)
@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, {})
@ddt.data(0, 1)
def test_get_course_programs_with_cache(self, ttl):
""" Test that the method 'get_course_programs_for_dashboard' with
cache_ttl greater than 0 saves the programs into cache and does not
hit the api again until the cached data expires.
"""
self.create_config(enabled=True, enable_student_dashboard=True, cache_ttl=ttl)
# 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']
)
self.assertTrue(mock_get.called)
self.assertEqual(self.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']
)
self.expected_output['edX/Program/Program_Run'] = self.edx_prg_run
# If cache_ttl value is 0 than cache will be considered as disabled.
# And mocked method will be call again
if ttl == 0:
self.assertTrue(mock_get.called)
else:
self.assertFalse(mock_get.called)
self.assertEqual(self.expected_output, programs)
self.assertEqual(
sorted(programs.keys()),
['edX/DemoX_1/Run_1', 'edX/DemoX_2/Run_2', 'edX/Program/Program_Run']
)
"""
Helper methods for Programs.
"""
"""Helper functions for working with Programs."""
import logging
from django.core.cache import cache
from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.lib.token_utils import get_id_token
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
log = logging.getLogger(__name__)
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
)
def get_programs(user):
"""Given a user, get programs from the Programs service.
Returned value is cached depending on user permissions. Staff users making requests
against Programs will receive unpublished programs, while regular users will only receive
published programs.
Arguments:
user (User): The user to authenticate as when requesting programs.
def is_cache_enabled_for_programs():
"""Returns a Boolean indicating whether responses from the Programs API
will be cached.
Returns:
list of dict, representing programs returned by the Programs service.
"""
return ProgramsApiConfig.current().is_cache_enabled
programs_config = ProgramsApiConfig.current()
no_programs = []
# Bypass caching for staff users, who may be creating Programs and want to see them displayed immediately.
use_cache = programs_config.is_cache_enabled and not user.is_staff
if not programs_config.enabled:
log.warning('Programs configuration is disabled.')
return no_programs
if use_cache:
cached = cache.get(programs_config.CACHE_KEY)
if cached is not None:
return cached
try:
jwt = get_id_token(user, programs_config.OAUTH2_CLIENT_NAME)
api = EdxRestApiClient(programs_config.internal_api_url, jwt=jwt)
except Exception: # pylint: disable=broad-except
log.exception('Failed to initialize the Programs API client.')
return no_programs
try:
response = api.programs.get()
except Exception: # pylint: disable=broad-except
log.exception('Failed to retrieve programs from the Programs API.')
return no_programs
def set_cached_programs_response(programs_data):
""" Set cache value for the programs data with specific ttl.
results = response.get('results', no_programs)
if use_cache:
cache.set(programs_config.CACHE_KEY, results, programs_config.cache_ttl)
return results
def get_programs_for_dashboard(user, course_keys):
"""Build a dictionary of programs, keyed by course.
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 course key.
Arguments:
programs_data (dict): Programs data in dictionary format
user (User): The user to authenticate as when requesting programs.
course_keys (list): List of course keys representing the courses in which
the given user has active enrollments.
Returns:
dict, containing programs keyed by course. Empty if programs cannot be retrieved.
"""
cache.set(
ProgramsApiConfig.PROGRAMS_API_CACHE_KEY,
programs_data,
ProgramsApiConfig.current().cache_ttl
)
programs_config = ProgramsApiConfig.current()
course_programs = {}
if not programs_config.is_student_dashboard_enabled:
log.debug('Display of programs on the student dashboard is disabled.')
return course_programs
programs = get_programs(user)
if not programs:
log.debug('No programs found for the user with ID %d.', user.id)
return course_programs
# Convert course keys to Unicode representation for efficient lookup.
course_keys = map(unicode, course_keys)
# Reindex the result returned by the Programs API from:
# program -> course code -> course run
# to:
# course run -> program
# Ignore course runs not present in the user's active enrollments.
for program in programs:
try:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
course_key = run['course_key']
if course_key in course_keys:
course_programs[course_key] = program
except KeyError:
log.exception('Unable to parse Programs API response: %r', program)
def get_cached_programs_response():
""" Get programs data from cache against cache key."""
cache_key = ProgramsApiConfig.PROGRAMS_API_CACHE_KEY
return cache.get(cache_key)
return course_programs
"""
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,
is_cache_enabled_for_programs,
get_cached_programs_response,
set_cached_programs_response,
)
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)
# If cache config is enabled then get the response from cache first.
if is_cache_enabled_for_programs():
cached_programs = get_cached_programs_response()
if cached_programs is not None:
return _get_user_course_programs(cached_programs, 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
# If cache config is enabled than set the cache.
if is_cache_enabled_for_programs():
set_cached_programs_response(programs)
return _get_user_course_programs(programs, course_keys)
def _get_user_course_programs(programs, users_enrolled_course_keys):
""" Parse the raw programs according to the users enrolled courses and
return the matched course runs.
Arguments:
programs (list): List containing the programs data.
users_enrolled_course_keys (list) : List of course keys in which the user is enrolled.
"""
# reindex the result from pgm -> course code -> course run
# to
# course run -> program, ignoring course runs not present in the dashboard enrollments
course_programs = {}
for program in programs:
try:
for course_code in program['course_codes']:
for run in course_code['run_modes']:
if run['course_key'] in users_enrolled_course_keys:
course_programs[run['course_key']] = program
except KeyError:
log.exception('Unable to parse Programs API response: %r', 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)
"""Tests covering utilities for working with ID tokens."""
import calendar
import datetime
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test.utils import override_settings
import freezegun
import jwt
from oauth2_provider.tests.factories import ClientFactory
from provider.constants import CONFIDENTIAL
from openedx.core.lib.token_utils import get_id_token
from student.tests.factories import UserFactory, UserProfileFactory
class TestIdTokenGeneration(TestCase):
"""Tests covering ID token generation."""
client_name = 'edx-dummy-client'
def setUp(self):
super(TestIdTokenGeneration, self).setUp()
self.oauth2_client = ClientFactory(name=self.client_name, client_type=CONFIDENTIAL)
self.user = UserFactory()
# Create a profile for the user
self.user_profile = UserProfileFactory(user=self.user)
@override_settings(OAUTH_OIDC_ISSUER='test-issuer', OAUTH_ID_TOKEN_EXPIRATION=1)
@freezegun.freeze_time('2015-01-01 12:00:00')
def test_get_id_token(self):
"""Verify that ID tokens are signed with the correct secret and generated with the correct claims."""
token = get_id_token(self.user, self.client_name)
payload = jwt.decode(
token,
self.oauth2_client.client_secret,
audience=self.oauth2_client.client_id,
issuer=settings.OAUTH_OIDC_ISSUER,
)
now = datetime.datetime.utcnow()
expiration = now + datetime.timedelta(seconds=settings.OAUTH_ID_TOKEN_EXPIRATION)
expected_payload = {
'preferred_username': self.user.username,
'name': self.user_profile.name,
'email': self.user.email,
'administrator': self.user.is_staff,
'iss': settings.OAUTH_OIDC_ISSUER,
'exp': calendar.timegm(expiration.utctimetuple()),
'iat': calendar.timegm(now.utctimetuple()),
'aud': self.oauth2_client.client_id,
'sub': self.user.id, # pylint: disable=no-member
}
self.assertEqual(payload, expected_payload)
def test_get_id_token_invalid_client(self):
"""Verify that ImproperlyConfigured is raised when an invalid client name is provided."""
with self.assertRaises(ImproperlyConfigured):
get_id_token(self.user, 'does-not-exist')
"""Utilities for working with ID tokens."""
import datetime
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import jwt
from provider.oauth2.models import Client
from student.models import UserProfile
def get_id_token(user, client_name):
"""Construct a JWT for use with the named client.
The JWT is signed with the named client's secret, and includes the following claims:
preferred_username (str): The user's username. The claim name is borrowed from edx-oauth2-provider.
name (str): The user's full name.
email (str): The user's email address.
administrator (Boolean): Whether the user has staff permissions.
iss (str): Registered claim. Identifies the principal that issued the JWT.
exp (int): Registered claim. Identifies the expiration time on or after which
the JWT must NOT be accepted for processing.
iat (int): Registered claim. Identifies the time at which the JWT was issued.
aud (str): Registered claim. Identifies the recipients that the JWT is intended for. This implementation
uses the named client's ID.
sub (int): Registered claim. Identifies the user. This implementation uses the raw user id.
Arguments:
user (User): User for which to generate the JWT.
client_name (unicode): Name of the OAuth2 Client for which the token is intended.
Returns:
str: the JWT
Raises:
ImproperlyConfigured: If no OAuth2 Client with the provided name exists.
"""
try:
client = Client.objects.get(name=client_name)
except Client.DoesNotExist:
raise ImproperlyConfigured('OAuth2 Client with name [%s] does not exist' % client_name)
user_profile = UserProfile.objects.get(user=user)
now = datetime.datetime.utcnow()
expires_in = getattr(settings, 'OAUTH_ID_TOKEN_EXPIRATION', 30)
payload = {
'preferred_username': user.username,
'name': user_profile.name,
'email': user.email,
'administrator': user.is_staff,
'iss': settings.OAUTH_OIDC_ISSUER,
'exp': now + datetime.timedelta(seconds=expires_in),
'iat': now,
'aud': client.client_id,
'sub': user.id,
}
return jwt.encode(payload, client.client_secret)
......@@ -98,6 +98,11 @@ class Env(object):
'edxnotes': {
'port': 8042,
'log': BOK_CHOY_LOG_DIR / "bok_choy_edxnotes.log",
},
'programs': {
'port': 8090,
'log': BOK_CHOY_LOG_DIR / "bok_choy_programs.log",
}
}
......
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