diff --git a/cms/djangoapps/contentstore/tests/test_programs.py b/cms/djangoapps/contentstore/tests/test_programs.py
new file mode 100644
index 0000000000..e2af560a3f
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_programs.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index 473fe058d3..680e4a5945 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -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"))
diff --git a/cms/envs/aws.py b/cms/envs/aws.py
index 80f2718b53..3d7093431d 100644
--- a/cms/envs/aws.py
+++ b/cms/envs/aws.py
@@ -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']
diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json
index 77d00876a2..d9856eec73 100644
--- a/cms/envs/bok_choy.env.json
+++ b/cms/envs/bok_choy.env.json
@@ -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"
}
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 930ab32438..47f604f1a1 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -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'
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index 6634a202ff..127eb8a3a1 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -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')):
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 186d34bb0c..45263bebdc 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.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
diff --git a/cms/lib/studio_tabs.py b/cms/lib/studio_tabs.py
deleted file mode 100644
index d91f6fa9d8..0000000000
--- a/cms/lib/studio_tabs.py
+++ /dev/null
@@ -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
diff --git a/cms/lib/tests/__init__.py b/cms/lib/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/cms/lib/tests/test_studio_tabs.py b/cms/lib/tests/test_studio_tabs.py
deleted file mode 100644
index 35140c5464..0000000000
--- a/cms/lib/tests/test_studio_tabs.py
+++ /dev/null
@@ -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
diff --git a/cms/static/js/index.js b/cms/static/js/index.js
index f130b73656..243c602125 100644
--- a/cms/static/js/index.js
+++ b/cms/static/js/index.js
@@ -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);
diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss
index c81690b4a2..42de626249 100644
--- a/cms/static/sass/views/_dashboard.scss
+++ b/cms/static/sass/views/_dashboard.scss
@@ -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;
diff --git a/cms/templates/index.html b/cms/templates/index.html
index 472156038c..7f9dc051f5 100644
--- a/cms/templates/index.html
+++ b/cms/templates/index.html
@@ -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',''):
${_("Email staff to create course")}
% endif
+
% if show_new_library_button:
${_("New Library")}
% endif
+
+ % if is_programs_enabled:
+
+
+ % endif
@@ -271,12 +279,19 @@
%endif
- %if libraries_enabled:
+ % if libraries_enabled or is_programs_enabled: