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 @@ ...@@ -2,97 +2,96 @@
Views related to operations on course objects Views related to operations on course objects
""" """
import copy import copy
from django.shortcuts import redirect
import json import json
import random
import logging import logging
import string import random
from django.utils.translation import ugettext as _ import string # pylint: disable=deprecated-module
import django.utils
from django.contrib.auth.decorators import login_required
from django.conf import settings 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.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404 from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, Http404
from util.json_request import JsonResponse, JsonResponseBadRequest from django.shortcuts import redirect
from util.date_utils import get_default_time_display import django.utils
from edxmako.shortcuts import render_to_response from django.utils.translation import ugettext as _
from django.views.decorators.http import require_http_methods, require_GET
from xmodule.course_module import DEFAULT_START_DATE from django.views.decorators.csrf import ensure_csrf_cookie
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 opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import Location
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import Location
from django.views.decorators.csrf import ensure_csrf_cookie from .component import (
from openedx.core.lib.js_utils import escape_json_dumps ADVANCED_COMPONENT_TYPES,
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update 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 ( from contentstore.course_group_config import (
COHORT_SCHEME,
GroupConfiguration, GroupConfiguration,
GroupConfigurationsValidationError, GroupConfigurationsValidationError,
RANDOM_SCHEME, 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.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from contentstore.push_notification import push_notification_enabled
from contentstore.tasks import rerun_course
from contentstore.utils import ( from contentstore.utils import (
add_instructor, add_instructor,
initialize_permissions, initialize_permissions,
get_lms_link_for_item, get_lms_link_for_item,
remove_all_instructors,
reverse_course_url, reverse_course_url,
reverse_library_url, reverse_library_url,
reverse_usage_url, reverse_usage_url,
reverse_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 ( from contentstore.views.entrance_exam import (
create_entrance_exam, create_entrance_exam,
delete_entrance_exam,
update_entrance_exam, update_entrance_exam,
delete_entrance_exam
) )
from course_action_state.managers import CourseActionStateItemNotFoundError
from .library import LIBRARIES_ENABLED from course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from .item import create_xblock_info
from contentstore.push_notification import push_notification_enabled
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested 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 ( from student.roles import (
CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff, UserBasedRole CourseInstructorRole, CourseStaffRole, CourseCreatorRole, GlobalStaff, UserBasedRole
) )
from student import auth from util.date_utils import get_default_time_display
from course_action_state.models import CourseRerunState, CourseRerunUIStateManager from util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
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.milestones_helpers import ( from util.milestones_helpers import (
set_prerequisite_courses, is_entrance_exams_enabled,
is_valid_course_key,
is_prerequisite_courses_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__) log = logging.getLogger(__name__)
...@@ -219,6 +218,7 @@ def _dismiss_notification(request, course_action_state_id): # pylint: disable=u ...@@ -219,6 +218,7 @@ def _dismiss_notification(request, course_action_state_id): # pylint: disable=u
return JsonResponse({'success': True}) return JsonResponse({'success': True})
# pylint: disable=unused-argument
@login_required @login_required
def course_handler(request, course_key_string=None): def course_handler(request, course_key_string=None):
""" """
...@@ -422,6 +422,13 @@ def course_listing(request): ...@@ -422,6 +422,13 @@ def course_listing(request):
courses, in_process_course_actions = get_courses_accessible_to_user(request) courses, in_process_course_actions = get_courses_accessible_to_user(request)
libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else [] 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): def format_in_process_course_view(uca):
""" """
Return a dict of the data which the view requires for each unsucceeded course Return a dict of the data which the view requires for each unsucceeded course
...@@ -470,7 +477,9 @@ def course_listing(request): ...@@ -470,7 +477,9 @@ def course_listing(request):
'course_creator_status': _get_course_creator_status(request.user), 'course_creator_status': _get_course_creator_status(request.user),
'rerun_creator_status': GlobalStaff().has_user(request.user), 'rerun_creator_status': GlobalStaff().has_user(request.user),
'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), '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): ...@@ -805,6 +814,7 @@ def _rerun_course(request, org, number, run, fields):
}) })
# pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
@require_http_methods(["GET"]) @require_http_methods(["GET"])
...@@ -837,6 +847,7 @@ def course_info_handler(request, course_key_string): ...@@ -837,6 +847,7 @@ def course_info_handler(request, course_key_string):
return HttpResponseBadRequest("Only supports html requests") return HttpResponseBadRequest("Only supports html requests")
# pylint: disable=unused-argument
@login_required @login_required
@ensure_csrf_cookie @ensure_csrf_cookie
@require_http_methods(("GET", "POST", "PUT", "DELETE")) @require_http_methods(("GET", "POST", "PUT", "DELETE"))
......
...@@ -364,3 +364,8 @@ XBLOCK_SETTINGS.setdefault("VideoModule", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.g ...@@ -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_BACKEND_PROVIDER = AUTH_TOKENS.get("PROCTORING_BACKEND_PROVIDER", PROCTORING_BACKEND_PROVIDER)
PROCTORING_SETTINGS = ENV_TOKENS.get("PROCTORING_SETTINGS", PROCTORING_SETTINGS) 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 @@ ...@@ -103,5 +103,6 @@
"TECH_SUPPORT_EMAIL": "technical@example.com", "TECH_SUPPORT_EMAIL": "technical@example.com",
"THEME_NAME": "", "THEME_NAME": "",
"TIME_ZONE": "America/New_York", "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 = ( ...@@ -806,6 +806,11 @@ INSTALLED_APPS = (
# Self-paced course configuration # Self-paced course configuration
'openedx.core.djangoapps.self_paced', '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 # 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 # other apps that are. Django 1.8 wants to have imported models supported
# by installed apps. # by installed apps.
...@@ -1112,3 +1117,9 @@ PROCTORING_BACKEND_PROVIDER = { ...@@ -1112,3 +1117,9 @@ PROCTORING_BACKEND_PROVIDER = {
'options': {}, 'options': {},
} }
PROCTORING_SETTINGS = {} 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 ...@@ -115,6 +115,9 @@ FEATURES['CERTIFICATES_HTML_VIEW'] = True
# Whether to run django-require in debug mode. # Whether to run django-require in debug mode.
REQUIRE_DEBUG = DEBUG REQUIRE_DEBUG = DEBUG
########################### OAUTH2 #################################
OAUTH_OIDC_ISSUER = 'http://127.0.0.1:8000/oauth2'
############################################################################### ###############################################################################
# See if the developer has any local overrides. # See if the developer has any local overrides.
if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')): if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')):
......
...@@ -33,9 +33,6 @@ from lms.envs.test import ( ...@@ -33,9 +33,6 @@ from lms.envs.test import (
DEFAULT_FILE_STORAGE, DEFAULT_FILE_STORAGE,
MEDIA_ROOT, MEDIA_ROOT,
MEDIA_URL, 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 # 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 ...@@ -141,20 +141,26 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
e.preventDefault(); e.preventDefault();
$('.courses-tab').toggleClass('active', tab === 'courses'); $('.courses-tab').toggleClass('active', tab === 'courses');
$('.libraries-tab').toggleClass('active', tab === 'libraries'); $('.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: // 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 () { var onReady = function () {
$('.new-course-button').bind('click', addNewCourse); $('.new-course-button').bind('click', addNewCourse);
$('.new-library-button').bind('click', addNewLibrary); $('.new-library-button').bind('click', addNewLibrary);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
ViewUtils.reload(); ViewUtils.reload();
})); }));
$('.action-reload').bind('click', ViewUtils.reload); $('.action-reload').bind('click', ViewUtils.reload);
$('#course-index-tabs .courses-tab').bind('click', showTab('courses')); $('#course-index-tabs .courses-tab').bind('click', showTab('courses'));
$('#course-index-tabs .libraries-tab').bind('click', showTab('libraries')); $('#course-index-tabs .libraries-tab').bind('click', showTab('libraries'));
$('#course-index-tabs .programs-tab').bind('click', showTab('programs'));
}; };
domReady(onReady); domReady(onReady);
......
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
} }
.action-create-course, .action-create-library { .action-create-course, .action-create-library, .action-create-program {
@extend %btn-primary-green; @extend %btn-primary-green;
@extend %t-action3; @extend %t-action3;
} }
...@@ -318,7 +318,7 @@ ...@@ -318,7 +318,7 @@
} }
// ELEM: course listings // ELEM: course listings
.courses-tab, .libraries-tab { .courses-tab, .libraries-tab, .programs-tab {
display: none; display: none;
&.active { &.active {
...@@ -326,7 +326,7 @@ ...@@ -326,7 +326,7 @@
} }
} }
.courses, .libraries { .courses, .libraries, .programs {
.title { .title {
@extend %t-title6; @extend %t-title6;
margin-bottom: $baseline; margin-bottom: $baseline;
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" /> <%inherit file="base.html" />
<%def name="online_help_token()"><% return "home" %></%def> <%def name="online_help_token()"><% return "home" %></%def>
<%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</%block> <%block name="title">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</%block>
<%block name="bodyclass">is-signedin index view-dashboard</%block> <%block name="bodyclass">is-signedin index view-dashboard</%block>
...@@ -27,10 +28,17 @@ ...@@ -27,10 +28,17 @@
% elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''): % 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> <a href="mailto:${settings.FEATURES.get('STUDIO_REQUEST_EMAIL','')}">${_("Email staff to create course")}</a>
% endif % endif
% if show_new_library_button: % if show_new_library_button:
<a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i> <a href="#" class="button new-button new-library-button"><i class="icon fa fa-plus icon-inline"></i>
${_("New Library")}</a> ${_("New Library")}</a>
% endif % 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> </li>
</ul> </ul>
</nav> </nav>
...@@ -271,12 +279,19 @@ ...@@ -271,12 +279,19 @@
</div> </div>
%endif %endif
%if libraries_enabled: % if libraries_enabled or is_programs_enabled:
<ul id="course-index-tabs"> <ul id="course-index-tabs">
<li class="courses-tab active"><a>${_("Courses")}</a></li> <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> </ul>
%endif % endif
%if len(courses) > 0: %if len(courses) > 0:
<div class="courses courses-tab active"> <div class="courses courses-tab active">
...@@ -485,6 +500,54 @@ ...@@ -485,6 +500,54 @@
</div> </div>
%endif %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> </article>
<aside class="content-supplementary" role="complementary"> <aside class="content-supplementary" role="complementary">
<div class="bit"> <div class="bit">
......
...@@ -955,7 +955,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -955,7 +955,7 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
@ddt.unpack @ddt.unpack
def test_get_xseries_programs_method(self, program_status, course_codes, marketing_slug): def test_get_xseries_programs_method(self, program_status, course_codes, marketing_slug):
"""Verify that program data is parsed correctly for a given course""" """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 = { mock_data.return_value = {
u'edx/demox/Run_1': { u'edx/demox/Run_1': {
'category': self.category, 'category': self.category,
...@@ -997,15 +997,10 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -997,15 +997,10 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
""" """
CourseEnrollment.enroll(self.user, self.course_1.id) CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test") self.client.login(username="jack", password="test")
with patch('student.views.get_course_programs_for_dashboard') as mock_method: with patch('student.views.get_programs_for_dashboard') as mock_method:
mock_method.return_value = self._create_program_data( mock_method.return_value = self._create_program_data([])
[(self.course_1.id, 'active')]
)
response = self.client.get(reverse('dashboard')) 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.assertEquals(response.status_code, 200)
self.assertIn('Pursue a Certificate of Achievement to highlight', response.content) self.assertIn('Pursue a Certificate of Achievement to highlight', response.content)
self._assert_responses(response, 0) self._assert_responses(response, 0)
...@@ -1020,9 +1015,9 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -1020,9 +1015,9 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_2.id, mode=course_mode) CourseEnrollment.enroll(self.user, self.course_2.id, mode=course_mode)
self.client.login(username="jack", password="test") 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( mock_data.return_value = self._create_program_data(
[(self.course_1.id, 'active'), (self.course_2.id, 'unpublished')] [(self.course_1.id, 'active'), (self.course_2.id, 'unpublished')]
) )
...@@ -1053,10 +1048,10 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -1053,10 +1048,10 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified') CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
self.client.login(username="jack", password="test") self.client.login(username="jack", password="test")
self.create_config(enabled=True, enable_student_dashboard=True) self.create_config()
with patch( 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')]) return_value=self._create_program_data([(self.course_1.id, 'active')])
) as mock_get_programs: ) as mock_get_programs:
response = self.client.get(reverse('dashboard')) response = self.client.get(reverse('dashboard'))
...@@ -1083,9 +1078,9 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -1083,9 +1078,9 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor') CourseEnrollment.enroll(self.user, self.course_3.id, mode='honor')
self.client.login(username="jack", password="test") 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( mock_data.return_value = self._create_program_data(
[(self.course_1.id, status_1), [(self.course_1.id, status_1),
(self.course_2.id, status_2), (self.course_2.id, status_2),
...@@ -1104,13 +1099,13 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -1104,13 +1099,13 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
CourseEnrollment.enroll(self.user, self.course_1.id) CourseEnrollment.enroll(self.user, self.course_1.id)
self.client.login(username="jack", password="test") 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')]) 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)]: if key_remove and key_remove in program_data[unicode(self.course_1.id)]:
del program_data[unicode(self.course_1.id)][key_remove] 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 mock_data.return_value = program_data
response = self.client.get(reverse('dashboard')) response = self.client.get(reverse('dashboard'))
......
...@@ -125,13 +125,12 @@ from notification_prefs.views import enable_notifications ...@@ -125,13 +125,12 @@ from notification_prefs.views import enable_notifications
# Note that this lives in openedx, so this dependency should be refactored. # 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.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 get_programs_for_dashboard
from openedx.core.djangoapps.programs.utils import is_student_dashboard_programs_enabled
log = logging.getLogger("edx.student") log = logging.getLogger("edx.student")
AUDIT_LOG = logging.getLogger("audit") 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' SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
...@@ -583,12 +582,10 @@ def dashboard(request): ...@@ -583,12 +582,10 @@ def dashboard(request):
and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview) and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
) )
# get the programs associated with courses being displayed. # Get any programs associated with courses being displayed.
# pass this along in template context in order to render additional # This is passed along in the template context to allow rendering of
# program-related information on the dashboard view. # program-related information on the dashboard.
course_programs = {} course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments])
if is_student_dashboard_programs_enabled():
course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments])
# Construct a dictionary of course mode information # Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict # used to render the course list. We re-use the course modes dict
...@@ -1632,7 +1629,7 @@ def create_account_with_params(request, params): ...@@ -1632,7 +1629,7 @@ def create_account_with_params(request, params):
if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY:
tracking_context = tracker.get_tracker().resolve_context() tracking_context = tracker.get_tracker().resolve_context()
identity_args = [ identity_args = [
user.id, user.id, # pylint: disable=no-member
{ {
'email': user.email, 'email': user.email,
'username': user.username, 'username': user.username,
...@@ -1895,13 +1892,13 @@ def auto_auth(request): ...@@ -1895,13 +1892,13 @@ def auto_auth(request):
'username': username, 'username': username,
'email': email, 'email': email,
'password': password, 'password': password,
'user_id': user.id, 'user_id': user.id, # pylint: disable=no-member
'anonymous_id': anonymous_id_for_user(user, None), 'anonymous_id': anonymous_id_for_user(user, None),
}) })
else: else:
success_msg = u"{} user {} ({}) with password {} and user_id {}".format( success_msg = u"{} user {} ({}) with password {} and user_id {}".format(
u"Logged in" if login_when_done else "Created", 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 = HttpResponse(success_msg)
response.set_cookie('csrftoken', csrf(request)['csrf_token']) response.set_cookie('csrftoken', csrf(request)['csrf_token'])
...@@ -2285,24 +2282,23 @@ def change_email_settings(request): ...@@ -2285,24 +2282,23 @@ def change_email_settings(request):
return JsonResponse({"success": True}) return JsonResponse({"success": True})
def _get_course_programs(user, user_enrolled_courses): def _get_course_programs(user, user_enrolled_courses): # pylint: disable=invalid-name
""" Returns a dictionary of programs courses data require for the student """Build a dictionary of program data required for display on the student dashboard.
dashboard.
Given a user and an iterable of course keys, find all Given a user and an iterable of course keys, find all programs relevant to the
the programs relevant to the user and return them in a user and return them in a dictionary keyed by course key.
dictionary keyed by the course_key.
Arguments: Arguments:
user (user object): Currently logged-in User user (User): The user to authenticate as when requesting programs.
user_enrolled_courses (list): List of course keys in which user is user_enrolled_courses (list): List of course keys representing the courses in which
enrolled the given user has active enrollments.
Returns: 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 = {} programs_data = {}
for course_key, program in course_programs.viewitems(): for course_key, program in course_programs.viewitems():
if program.get('status') == 'active' and program.get('category') == 'xseries': if program.get('status') == 'active' and program.get('category') == 'xseries':
try: 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 ...@@ -11,6 +11,7 @@ from .ora import StubOraService
from .lti import StubLtiService from .lti import StubLtiService
from .video_source import VideoSourceHttpService from .video_source import VideoSourceHttpService
from .edxnotes import StubEdxNotesService from .edxnotes import StubEdxNotesService
from .programs import StubProgramsService
USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]" USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]"
...@@ -23,6 +24,7 @@ SERVICES = { ...@@ -23,6 +24,7 @@ SERVICES = {
'lti': StubLtiService, 'lti': StubLtiService,
'video': VideoSourceHttpService, 'video': VideoSourceHttpService,
'edxnotes': StubEdxNotesService, 'edxnotes': StubEdxNotesService,
'programs': StubProgramsService,
} }
# Log to stdout, including debug messages # Log to stdout, including debug messages
......
...@@ -17,3 +17,6 @@ COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567') ...@@ -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 # Get the URL of the EdxNotes service stub used in the test
EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042') 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): ...@@ -128,3 +128,49 @@ class DashboardPage(PageObject):
if all([lib[key] == kwargs[key] for key in kwargs]): if all([lib[key] == kwargs[key] for key in kwargs]):
return True return True
return False 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). ...@@ -4,9 +4,12 @@ Acceptance tests for Home Page (My Courses / My Libraries).
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import LibraryLocator 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.auto_auth import AutoAuthPage
from ...pages.studio.library import LibraryEditPage from ...pages.studio.library import LibraryEditPage
from ...pages.studio.index import DashboardPage from ...pages.studio.index import DashboardPage, DashboardPageWithPrograms
class CreateLibraryTest(WebAppTest): class CreateLibraryTest(WebAppTest):
...@@ -55,3 +58,84 @@ 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: # Then go back to the home page and make sure the new library is listed there:
self.dashboard_page.visit() self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number)) 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 ...@@ -22,7 +22,7 @@ from edxnotes.exceptions import EdxNotesParseError, EdxNotesServiceUnavailable
from capa.util import sanitize_html from capa.util import sanitize_html
from courseware.views import get_current_child from courseware.views import get_current_child
from courseware.access import has_access 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 student.models import anonymous_id_for_user
from util.date_utils import get_default_time_display from util.date_utils import get_default_time_display
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
......
...@@ -13,6 +13,7 @@ from microsite_configuration import microsite ...@@ -13,6 +13,7 @@ from microsite_configuration import microsite
import auth_exchange.views import auth_exchange.views
from config_models.views import ConfigurationModelCurrentAPIView from config_models.views import ConfigurationModelCurrentAPIView
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
# Uncomment the next two lines to enable the admin: # Uncomment the next two lines to enable the admin:
...@@ -738,6 +739,7 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"): ...@@ -738,6 +739,7 @@ if settings.FEATURES.get("ENABLE_LTI_PROVIDER"):
urlpatterns += ( urlpatterns += (
url(r'config/self_paced', ConfigurationModelCurrentAPIView.as_view(model=SelfPacedConfiguration)), url(r'config/self_paced', ConfigurationModelCurrentAPIView.as_view(model=SelfPacedConfiguration)),
url(r'config/programs', ConfigurationModelCurrentAPIView.as_view(model=ProgramsApiConfig)),
) )
urlpatterns = patterns(*urlpatterns) urlpatterns = patterns(*urlpatterns)
......
Open edX Open edX
-------- --------
This is the root package for Open edX. This is the root package for Open edX. The intent is that all importable code
The intent is that all importable code from Open edX will eventually live here, from Open edX will eventually live here, including the code in the lms, cms,
including the code in the lms, cms, and common directories. and common directories.
Note: for now the code is not structured like this, and hence legacy code will If you're adding a new Django app, place it in core/djangoapps. If you're adding
continue to live in a number of different packages. All new code should be code that defines no Django models or views of its own but is widely useful, put it
created in this package, and the legacy code will be moved here gradually. 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 urlparse import urljoin
from django.db.models import NullBooleanField, IntegerField, URLField
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.db import models from django.db import models
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
AuthoringAppConfig = namedtuple('AuthoringAppConfig', ['js_url', 'css_url'])
class ProgramsApiConfig(ConfigurationModel): class ProgramsApiConfig(ConfigurationModel):
""" """
Manages configuration for connecting to the Programs service and using its Manages configuration for connecting to the Programs service and using its
API. 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( cache_ttl = models.PositiveIntegerField(
verbose_name=_("Cache Time To Live"), verbose_name=_("Cache Time To Live"),
default=0, default=0,
...@@ -29,31 +49,62 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -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 @property
def internal_api_url(self): 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 @property
def public_api_url(self): 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 @property
def is_student_dashboard_enabled(self): 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. be enabled or not.
""" """
return self.enabled and self.enable_student_dashboard return self.enabled and self.enable_student_dashboard
@property @property
def is_cache_enabled(self): def is_studio_tab_enabled(self):
"""Whether responses from the Programs API will be cached.""" """
return self.enabled and self.cache_ttl > 0 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)
)
""" """Mixins for use during testing."""
Broadly-useful mixins for use in automated tests. import json
"""
import httpretty
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
class ProgramsApiConfigMixin(object): class ProgramsApiConfigMixin(object):
""" """Utilities for working with Programs configuration during testing."""
Programs api configuration utility methods for testing.
"""
INTERNAL_URL = "http://internal/"
PUBLIC_URL = "http://public/"
DEFAULTS = dict( DEFAULTS = {
internal_service_url=INTERNAL_URL, 'enabled': True,
public_service_url=PUBLIC_URL, 'api_version_number': 1,
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): def create_config(self, **kwargs):
""" """Creates a new ProgramsApiConfig with DEFAULTS, updated with any provided overrides."""
DRY helper. Create a new ProgramsApiConfig with self.DEFAULTS, updated fields = dict(self.DEFAULTS, **kwargs)
with any kwarg overrides. ProgramsApiConfig(**fields).save()
"""
ProgramsApiConfig(**dict(self.DEFAULTS, **kwargs)).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 import ddt
from mock import patch
from django.test import TestCase from django.test import TestCase
import mock
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
@ddt.ddt @ddt.ddt
@patch('config_models.models.cache.get', return_value=None) # during tests, make every cache get a miss. # ConfigurationModels use the cache. Make every cache get a miss.
class ProgramsApiConfigTest(ProgramsApiConfigMixin, TestCase): @mock.patch('config_models.models.cache.get', return_value=None)
""" class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
Tests for the ProgramsApiConfig model. """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): self.assertEqual(
""" programs_config.internal_api_url,
Ensure the config stores empty values when no data has been inserted, programs_config.internal_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
and is completely disabled. )
""" self.assertEqual(
self.assertFalse(ProgramsApiConfig.is_enabled()) programs_config.public_api_url,
api_config = ProgramsApiConfig.current() programs_config.public_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
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)
def test_created_state(self, _mock_cache): authoring_app_config = programs_config.authoring_app_config
"""
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)
def test_api_urls(self, _mock_cache): self.assertEqual(
""" authoring_app_config.js_url,
Ensure the api url methods return correct concatenations of service programs_config.public_service_url.strip('/') + programs_config.authoring_app_js_path
URLs and version numbers. )
""" self.assertEqual(
self.create_config() authoring_app_config.css_url,
api_config = ProgramsApiConfig.current() programs_config.public_service_url.strip('/') + programs_config.authoring_app_css_path
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))
@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): def test_is_student_dashboard_enabled(self, _mock_cache):
""" """
Ensure that is_student_dashboard_enabled only returns True when the Verify that the property controlling display on the student dashboard is only True
current config has both 'enabled' and 'enable_student_dashboard' set to when configuration is enabled and all required configuration is provided.
True.
""" """
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()
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled)
self.create_config(enabled=True) programs_config = self.create_config(enable_student_dashboard=False)
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled) self.assertFalse(programs_config.is_student_dashboard_enabled)
self.create_config(enable_student_dashboard=True) programs_config = self.create_config()
self.assertFalse(ProgramsApiConfig.current().is_student_dashboard_enabled) self.assertTrue(programs_config.is_student_dashboard_enabled)
self.create_config(enabled=True, enable_student_dashboard=True) def test_is_studio_tab_enabled(self, _mock_cache):
self.assertTrue(ProgramsApiConfig.current().is_student_dashboard_enabled) """
Verify that the property controlling display of the Studio tab is only True
@ddt.data( when configuration is enabled and all required configuration is provided.
(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.
""" """
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( programs_config = self.create_config(enable_studio_tab=False)
enabled=enabled, self.assertFalse(programs_config.is_studio_tab_enabled)
cache_ttl=cache_ttl
)
self.assertFalse(ProgramsApiConfig.current().is_cache_enabled)
def test_is_cache_enabled_returns_true(self, _mock_cache): programs_config = self.create_config(authoring_app_js_path='', authoring_app_css_path='')
""""Verify that is_cache_enabled returns True when Programs is enabled self.assertFalse(programs_config.is_studio_tab_enabled)
and the cache TTL is greater than 0."
""" programs_config = self.create_config()
self.create_config(enabled=True, cache_ttl=10) self.assertTrue(programs_config.is_studio_tab_enabled)
self.assertTrue(ProgramsApiConfig.current().is_cache_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, {})
""" """Helper functions for working with Programs."""
Helper methods for Programs. import logging
"""
from django.core.cache import cache from django.core.cache import cache
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.programs.models import ProgramsApiConfig 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 log = logging.getLogger(__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): def get_programs(user):
""" Returns an Programs API client setup with authentication for the """Given a user, get programs from the Programs service.
specified user.
""" Returned value is cached depending on user permissions. Staff users making requests
return EdxRestApiClient( against Programs will receive unpublished programs, while regular users will only receive
api_url, published programs.
jwt=jwt_access_token
)
Arguments:
user (User): The user to authenticate as when requesting programs.
def is_cache_enabled_for_programs(): Returns:
"""Returns a Boolean indicating whether responses from the Programs API list of dict, representing programs returned by the Programs service.
will be cached.
""" """
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): results = response.get('results', no_programs)
""" Set cache value for the programs data with specific ttl.
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: 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( programs_config = ProgramsApiConfig.current()
ProgramsApiConfig.PROGRAMS_API_CACHE_KEY, course_programs = {}
programs_data,
ProgramsApiConfig.current().cache_ttl 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(): return course_programs
""" Get programs data from cache against cache key."""
cache_key = ProgramsApiConfig.PROGRAMS_API_CACHE_KEY
return cache.get(cache_key)
"""
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): ...@@ -98,6 +98,11 @@ class Env(object):
'edxnotes': { 'edxnotes': {
'port': 8042, 'port': 8042,
'log': BOK_CHOY_LOG_DIR / "bok_choy_edxnotes.log", '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