Add Programs tab to Studio
Extends the Programs ConfigurationModel, cleans up Programs-related utilities and corresponding tests, and corrects caching. Uses the Programs API to list programs within Studio. ECOM-2769.
This commit is contained in:
66
cms/djangoapps/contentstore/tests/test_programs.py
Normal file
66
cms/djangoapps/contentstore/tests/test_programs.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""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 opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import Location
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
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 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 opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locations import Location
|
||||
|
||||
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 contentstore.views.entrance_exam import (
|
||||
create_entrance_exam,
|
||||
delete_entrance_exam,
|
||||
update_entrance_exam,
|
||||
)
|
||||
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 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 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,
|
||||
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_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
from contentstore import utils
|
||||
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
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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
|
||||
@@ -1,35 +0,0 @@
|
||||
"""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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%! 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:
|
||||
|
||||
41
common/djangoapps/terrain/stubs/programs.py
Normal file
41
common/djangoapps/terrain/stubs/programs.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
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')
|
||||
|
||||
61
common/test/acceptance/fixtures/programs.py
Normal file
61
common/test/acceptance/fixtures/programs.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
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())
|
||||
|
||||
15
common/test/db_fixtures/programs_client.json
Normal file
15
common/test/db_fixtures/programs_client.json
Normal file
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"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:
|
||||
@@ -743,6 +744,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)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +1,46 @@
|
||||
"""
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -1,27 +1,199 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
"""Utilities for working with Programs configuration during testing."""
|
||||
|
||||
INTERNAL_URL = "http://internal/"
|
||||
PUBLIC_URL = "http://public/"
|
||||
|
||||
DEFAULTS = dict(
|
||||
internal_service_url=INTERNAL_URL,
|
||||
public_service_url=PUBLIC_URL,
|
||||
api_version_number=1,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -1,97 +1,78 @@
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
programs_config = self.create_config(enable_student_dashboard=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()
|
||||
self.assertTrue(programs_config.is_student_dashboard_enabled)
|
||||
|
||||
self.create_config(enable_student_dashboard=True)
|
||||
self.assertFalse(ProgramsApiConfig.current().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):
|
||||
"""
|
||||
self.assertFalse(ProgramsApiConfig.current().is_cache_enabled)
|
||||
|
||||
self.create_config(
|
||||
enabled=enabled,
|
||||
cache_ttl=cache_ttl
|
||||
)
|
||||
self.assertFalse(ProgramsApiConfig.current().is_cache_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."
|
||||
Verify that the property controlling display of the Studio tab is only True
|
||||
when configuration is enabled and all required configuration is provided.
|
||||
"""
|
||||
self.create_config(enabled=True, cache_ttl=10)
|
||||
self.assertTrue(ProgramsApiConfig.current().is_cache_enabled)
|
||||
programs_config = self.create_config(enabled=False)
|
||||
self.assertFalse(programs_config.is_studio_tab_enabled)
|
||||
|
||||
programs_config = self.create_config(enable_studio_tab=False)
|
||||
self.assertFalse(programs_config.is_studio_tab_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)
|
||||
|
||||
131
openedx/core/djangoapps/programs/tests/test_utils.py
Normal file
131
openedx/core/djangoapps/programs/tests/test_utils.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""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, {})
|
||||
@@ -1,297 +0,0 @@
|
||||
"""
|
||||
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']
|
||||
)
|
||||
@@ -1,49 +1,107 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
|
||||
def is_cache_enabled_for_programs():
|
||||
"""Returns a Boolean indicating whether responses from the Programs API
|
||||
will be cached.
|
||||
"""
|
||||
return ProgramsApiConfig.current().is_cache_enabled
|
||||
|
||||
|
||||
def set_cached_programs_response(programs_data):
|
||||
""" Set cache value for the programs data with specific ttl.
|
||||
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:
|
||||
programs_data (dict): Programs data in dictionary format
|
||||
user (User): The user to authenticate as when requesting programs.
|
||||
|
||||
Returns:
|
||||
list of dict, representing programs returned by the Programs service.
|
||||
"""
|
||||
cache.set(
|
||||
ProgramsApiConfig.PROGRAMS_API_CACHE_KEY,
|
||||
programs_data,
|
||||
ProgramsApiConfig.current().cache_ttl
|
||||
)
|
||||
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
|
||||
|
||||
results = response.get('results', no_programs)
|
||||
|
||||
if use_cache:
|
||||
cache.set(programs_config.CACHE_KEY, results, programs_config.cache_ttl)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
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)
|
||||
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:
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
return course_programs
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,48 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
64
openedx/core/lib/tests/test_token_utils.py
Normal file
64
openedx/core/lib/tests/test_token_utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""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')
|
||||
60
openedx/core/lib/token_utils.py
Normal file
60
openedx/core/lib/token_utils.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user