Commit 31f5c8dd by Renzo Lucioni

Remove the program admin tool from Studio

All programs are now managed using the Django admin on the catalog service. This is the first in a series of commits removing use of the old programs service from edx-platform.

ECOM-4422
parent 41ea1383
......@@ -64,8 +64,6 @@ 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.models.course_details import CourseDetails
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.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.course_tabs import CourseTabPluginManager
......@@ -469,13 +467,6 @@ 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
......@@ -525,9 +516,6 @@ def course_listing(request):
'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),
'is_programs_enabled': programs_config.is_studio_tab_enabled and request.user.is_staff,
'programs': programs,
'program_authoring_url': reverse('programs'),
})
......
"""Programs views for use with Studio."""
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ImproperlyConfigured
from django.core.urlresolvers import reverse
from django.http import Http404, JsonResponse
from django.utils.decorators import method_decorator
from django.views.generic import View
from provider.oauth2.models import Client
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.lib.token_utils import JwtBuilder
class ProgramAuthoringView(View):
"""View rendering a template which hosts the Programs authoring app.
The Programs authoring app is a Backbone SPA. The app handles its own routing
and provides a UI which can be used to create and publish new Programs.
"""
@method_decorator(login_required)
def get(self, request, *args, **kwargs):
"""Populate the template context with values required for the authoring app to run."""
programs_config = ProgramsApiConfig.current()
if programs_config.is_studio_tab_enabled and request.user.is_staff:
return render_to_response('program_authoring.html', {
'lms_base_url': '//{}/'.format(settings.LMS_BASE),
'programs_api_url': programs_config.public_api_url,
'programs_token_url': reverse('programs_id_token'),
'studio_home_url': reverse('home'),
'uses_pattern_library': True
})
else:
raise Http404
class ProgramsIdTokenView(View):
"""Provides id tokens to JavaScript clients for use with the Programs API."""
@method_decorator(login_required)
def get(self, request, *args, **kwargs):
"""Generate and return a token, if the integration is enabled."""
if ProgramsApiConfig.current().is_studio_tab_enabled:
# TODO: Use the system's JWT_AUDIENCE and JWT_SECRET_KEY instead of client ID and name.
client_name = 'programs'
try:
client = Client.objects.get(name=client_name)
except Client.DoesNotExist:
raise ImproperlyConfigured(
'OAuth2 Client with name [{}] does not exist.'.format(client_name)
)
scopes = ['email', 'profile']
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
jwt = JwtBuilder(request.user, secret=client.client_secret).build_token(
scopes,
expires_in,
aud=client.client_id
)
return JsonResponse({'id_token': jwt})
else:
raise Http404
"""Tests covering the Programs listing on the Studio home."""
import json
from django.conf import settings
from django.core.urlresolvers import reverse
import httpretty
import mock
from edx_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.djangolib.markup import Text
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModuleStoreTestCase):
"""Verify Program listing behavior."""
def setUp(self):
super(TestProgramListing, self).setUp()
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
self.staff = UserFactory(is_staff=True)
self.client.login(username=self.staff.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_programs_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 permissions.
"""
student = UserFactory(is_staff=False)
self.client.login(username=student.username, password='test')
self.create_programs_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."""
# When no data is provided, expect creation prompt.
self.create_programs_config()
self.mock_programs_api(data={'results': []})
response = self.client.get(self.studio_home)
self.assertIn(Text("You haven't created any programs yet."), response.content.decode('utf-8'))
# 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)
class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Verify the behavior of the program authoring app's host view."""
def setUp(self):
super(TestProgramAuthoringView, self).setUp()
self.staff = UserFactory(is_staff=True)
self.programs_path = reverse('programs')
def _assert_status(self, status_code):
"""Verify the status code returned by the Program authoring view."""
response = self.client.get(self.programs_path)
self.assertEquals(response.status_code, status_code)
return response
def test_authoring_login_required(self):
"""Verify that accessing the view requires the user to be authenticated."""
response = self.client.get(self.programs_path)
self.assertRedirects(
response,
'{login_url}?next={programs}'.format(
login_url=settings.LOGIN_URL,
programs=self.programs_path
)
)
def test_authoring_header(self):
"""Verify that the header contains the expected text."""
self.client.login(username=self.staff.username, password='test')
self.create_programs_config()
response = self._assert_status(200)
self.assertIn("Program Administration", response.content)
def test_authoring_access(self):
"""
Verify that a 404 is returned if Programs authoring is disabled, or the user does not have
global staff permissions.
"""
self.client.login(username=self.staff.username, password='test')
self._assert_status(404)
# Enable Programs authoring interface
self.create_programs_config()
student = UserFactory(is_staff=False)
self.client.login(username=student.username, password='test')
self._assert_status(404)
class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Tests for the programs id_token endpoint."""
def setUp(self):
super(TestProgramsIdTokenView, self).setUp()
self.user = UserFactory()
self.client.login(username=self.user.username, password='test')
self.path = reverse('programs_id_token')
def test_config_disabled(self):
"""Ensure the endpoint returns 404 when Programs authoring is disabled."""
self.create_programs_config(enable_studio_tab=False)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 404)
def test_not_logged_in(self):
"""Ensure the endpoint denies access to unauthenticated users."""
self.create_programs_config()
self.client.logout()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 302)
self.assertIn(settings.LOGIN_URL, response['Location'])
@mock.patch('cms.djangoapps.contentstore.views.program.JwtBuilder.build_token')
def test_config_enabled(self, mock_build_token):
"""
Ensure the endpoint responds with a valid JSON payload when authoring
is enabled.
"""
mock_build_token.return_value = 'test-id-token'
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
self.create_programs_config()
response = self.client.get(self.path)
self.assertEqual(response.status_code, 200)
payload = json.loads(response.content)
self.assertEqual(payload, {'id_token': 'test-id-token'})
......@@ -908,9 +908,6 @@ INSTALLED_APPS = (
# Bookmarks
'openedx.core.djangoapps.bookmarks',
# programs support
'openedx.core.djangoapps.programs',
# Catalog integration
'openedx.core.djangoapps.catalog',
......
......@@ -53,8 +53,7 @@
'js/factories/settings_graders',
'js/factories/textbooks',
'js/factories/videos_index',
'js/factories/xblock_validation',
'js/programs/program_admin_app'
'js/factories/xblock_validation'
]),
/**
* By default all the configuration for optimization happens from the command
......
......@@ -59,7 +59,6 @@
'underscore.string': 'common/js/vendor/underscore.string',
'backbone': 'common/js/vendor/backbone',
'backbone-relational': 'js/vendor/backbone-relational.min',
'backbone.validation': 'common/js/vendor/backbone-validation-min',
'backbone.associations': 'js/vendor/backbone-associations-min',
'backbone.paginator': 'common/js/vendor/backbone.paginator',
'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min',
......
......@@ -56,7 +56,6 @@
'backbone': 'common/js/vendor/backbone',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
'backbone.paginator': 'common/js/vendor/backbone.paginator',
'backbone.validation': 'common/js/vendor/backbone-validation-min',
'backbone-relational': 'xmodule_js/common_static/js/vendor/backbone-relational.min',
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
......@@ -290,10 +289,7 @@
'js/certificates/spec/views/certificate_details_spec',
'js/certificates/spec/views/certificate_editor_spec',
'js/certificates/spec/views/certificates_list_spec',
'js/certificates/spec/views/certificate_preview_spec',
'js/spec/models/auto_auth_model_spec',
'js/spec/views/programs/program_creator_spec',
'js/spec/views/programs/program_details_spec'
'js/certificates/spec/views/certificate_preview_spec'
];
i = 0;
......
......@@ -37,7 +37,6 @@
'backbone': 'common/js/vendor/backbone',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
'backbone.paginator': 'common/js/vendor/backbone.paginator',
'backbone.validation': 'common/js/vendor/backbone-validation',
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
'xmodule': 'xmodule_js/src/xmodule',
......
......@@ -162,7 +162,6 @@ 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 !== 'courses');
......@@ -181,7 +180,6 @@ define(['domReady', 'jquery', 'underscore', 'js/utils/cancel_on_escape', 'js/vie
$('#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);
......
define([
'backbone',
'jquery',
'js/programs/utils/api_config',
'jquery.cookie'
],
function(Backbone, $, apiConfig) {
'use strict';
return Backbone.Collection.extend({
allRuns: [],
initialize: function(models, options) {
// Ignore pagination and give me everything
var orgStr = options.organization.key,
queries = '?org=' + orgStr + '&page_size=1000';
this.url = apiConfig.get('lmsBaseUrl') + 'api/courses/v1/courses/' + queries;
},
/*
* Abridged version of Backbone.Collection.Create that does not
* save the updated Collection back to the server
* (code based on original function - http://backbonejs.org/docs/backbone.html#section-134)
*/
create: function(model, options) {
options = options ? _.clone(options) : {};
model = this._prepareModel(model, options);
if (!!model) {
this.add(model, options);
return model;
}
},
parse: function(data) {
this.allRuns = data.results;
// Because pagination is ignored just set results
return data.results;
},
// Adds a run back into the model for selection
addRun: function(id) {
var courseRun = _.findWhere(this.allRuns, {id: id});
this.create(courseRun);
},
// Removes a run from the model for selection
removeRun: function(id) {
var courseRun = this.where({id: id});
this.remove(courseRun);
}
});
}
);
define([
'backbone',
'jquery',
'js/programs/models/program_model'
],
function(Backbone, $, ProgramModel) {
'use strict';
return Backbone.Collection.extend({
model: ProgramModel
});
}
);
define([
'backbone'
],
function(Backbone) {
'use strict';
return Backbone.Model.extend({
defaults: {
username: '',
lmsBaseUrl: '',
programsApiUrl: '',
authUrl: '/programs/id_token/',
idToken: ''
}
});
}
);
define([
'backbone',
'js/programs/utils/auth_utils'
],
function(Backbone, auth) {
'use strict';
return Backbone.Model.extend(auth.autoSync);
}
);
define([
'backbone',
'jquery',
'js/programs/utils/api_config',
'js/programs/models/auto_auth_model',
'jquery.cookie',
'gettext'
],
function(Backbone, $, apiConfig, AutoAuthModel) {
'use strict';
return AutoAuthModel.extend({
validation: {
key: {
required: true,
maxLength: 64
},
display_name: {
required: true,
maxLength: 128
}
},
labels: {
key: gettext('Course Code'),
display_name: gettext('Course Title')
},
defaults: {
display_name: false,
key: false,
organization: [],
run_modes: []
}
});
}
);
define([
'backbone'
],
function(Backbone) {
'use strict';
return Backbone.Model.extend({
defaults: {
course_key: '',
mode_slug: 'verified',
sku: '',
start_date: '',
run_key: ''
}
});
}
);
define([
'js/programs/utils/api_config',
'js/programs/models/auto_auth_model'
],
function(apiConfig, AutoAuthModel) {
'use strict';
return AutoAuthModel.extend({
url: function() {
return apiConfig.get('programsApiUrl') + 'organizations/?page_size=1000';
}
});
}
);
define([
'backbone',
'jquery',
'js/programs/utils/api_config',
'js/programs/models/auto_auth_model',
'gettext',
'jquery.cookie'
],
function(Backbone, $, apiConfig, AutoAuthModel, gettext) {
'use strict';
return AutoAuthModel.extend({
// Backbone.Validation rules.
// See: http://thedersen.com/projects/backbone-validation/#configure-validation-rules-on-the-model.
validation: {
name: {
required: true,
maxLength: 255
},
subtitle: {
// The underlying Django model does not require a subtitle.
maxLength: 255
},
category: {
required: true,
// TODO: Populate with the results of an API call for valid categories.
oneOf: ['XSeries', 'MicroMasters']
},
organizations: 'validateOrganizations',
marketing_slug: {
maxLength: 255
}
},
initialize: function() {
this.url = apiConfig.get('programsApiUrl') + 'programs/' + this.id + '/';
},
validateOrganizations: function(orgArray) {
/**
* The array passed to this method contains a single object representing
* the selected organization; the object contains the organization's key.
* In the future, multiple organizations might be associated with a program.
*/
var i,
len = orgArray ? orgArray.length : 0;
for (i = 0; i < len; i++) {
if (orgArray[i].key === 'false') {
return gettext('Please select a valid organization.');
}
}
},
getConfig: function(options) {
var patch = options && options.patch,
params = patch ? this.get('id') + '/' : '',
config = _.extend({validate: true, parse: true}, {
type: patch ? 'PATCH' : 'POST',
url: apiConfig.get('programsApiUrl') + 'programs/' + params,
contentType: patch ? 'application/merge-patch+json' : 'application/json',
context: this,
// NB: setting context fails in tests
success: _.bind(this.saveSuccess, this),
error: _.bind(this.saveError, this)
});
if (patch) {
config.data = JSON.stringify(options.update) || this.attributes;
}
return config;
},
patch: function(data) {
this.save({
patch: true,
update: data
});
},
save: function(options) {
var method,
patch = options && options.patch ? true : false,
config = this.getConfig(options);
/**
* Simplified version of code from the default Backbone save function
* http://backbonejs.org/docs/backbone.html#section-87
*/
method = this.isNew() ? 'create' : (patch ? 'patch' : 'update');
this.sync(method, this, config);
},
saveError: function(jqXHR) {
this.trigger('error', jqXHR);
},
saveSuccess: function(data) {
this.set({id: data.id});
this.trigger('sync', this);
}
});
}
);
(function() {
'use strict';
require([
'js/programs/views/program_admin_app_view'
],
function(ProgramAdminAppView) {
return new ProgramAdminAppView();
}
);
})();
define([
'backbone',
'js/programs/views/program_creator_view',
'js/programs/views/program_details_view',
'js/programs/models/program_model'
],
function(Backbone, ProgramCreatorView, ProgramDetailsView, ProgramModel) {
'use strict';
return Backbone.Router.extend({
root: '/program/',
routes: {
'new': 'programCreator',
':id': 'programDetails'
},
initialize: function(options) {
this.homeUrl = options.homeUrl;
},
goHome: function() {
window.location.href = this.homeUrl;
},
loadProgramDetails: function() {
this.programDetailsView = new ProgramDetailsView({
model: this.programModel
});
},
programCreator: function() {
if (this.programCreatorView) {
this.programCreatorView.destroy();
}
this.programCreatorView = new ProgramCreatorView({
router: this
});
},
programDetails: function(id) {
this.programModel = new ProgramModel({
id: id
});
this.programModel.on('sync', this.loadProgramDetails, this);
this.programModel.fetch();
},
/**
* Starts the router.
*/
start: function() {
if (!Backbone.history.started) {
Backbone.history.start({
pushState: true,
root: this.root
});
}
return this;
}
});
}
);
/**
* the Programs application loads gettext identity library via django, thus
* components reference gettext globally so a shim is added here to reflect
* the text so tests can be run if modules reference gettext
*/
(function() {
'use strict';
if (!window.gettext) {
window.gettext = function(text) {
return text;
};
}
if (!window.interpolate) {
window.interpolate = function(text) {
return text;
};
}
return window;
})();
define([
'js/programs/models/api_config_model'
],
function(ApiConfigModel) {
'use strict';
/**
* This js module implements the Singleton pattern for an instance
* of the ApiConfigModel Backbone model. It returns the same shared
* instance of that model anywhere it is required.
*/
var instance;
if (instance === undefined) {
instance = new ApiConfigModel();
}
return instance;
}
);
define([
'jquery',
'underscore',
'js/programs/utils/api_config'
],
function($, _, apiConfig) {
'use strict';
var auth = {
autoSync: {
/**
* Override Backbone.sync to seamlessly attempt (re-)authentication when necessary.
*
* If a 401 error response is encountered while making a request to the Programs,
* API, this wrapper will attempt to request an id token from a custom endpoint
* via AJAX. Then the original request will be retried once more.
*
* Any other response than 401 on the original API request, or any error occurring
* on the retried API request (including 401), will be handled by the base sync
* implementation.
*
*/
sync: function(method, model, options) {
var oldError = options.error;
this._setHeaders(options);
options.notifyOnError = false; // suppress Studio error pop-up that will happen if we get a 401
options.error = function(xhr, textStatus, errorThrown) {
if (xhr && xhr.status === 401) {
// attempt auth and retry
this._updateToken(function() {
// restore the original error handler
options.error = oldError;
options.notifyOnError = true; // if it fails again, let Studio notify.
delete options.xhr; // remove the failed (401) xhr from the last try.
// update authorization header
this._setHeaders(options);
Backbone.sync.call(this, method, model, options);
}.bind(this));
} else if (oldError) {
// fall back to the original error handler
oldError.call(this, xhr, textStatus, errorThrown);
}
}.bind(this);
return Backbone.sync.call(this, method, model, options);
},
/**
* Fix up headers on an imminent AJAX sync, ensuring that the JWT token is enclosed
* and that credentials are included when the request is being made cross-domain.
*/
_setHeaders: function(ajaxOptions) {
ajaxOptions.headers = _.extend(ajaxOptions.headers || {}, {
Authorization: 'JWT ' + apiConfig.get('idToken')
});
ajaxOptions.xhrFields = _.extend(ajaxOptions.xhrFields || {}, {
withCredentials: true
});
},
/**
* Fetch a new id token from the configured endpoint, update the api config,
* and invoke the specified callback.
*/
_updateToken: function(success) {
$.ajax({
url: apiConfig.get('authUrl'),
xhrFields: {
// See: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
withCredentials: true
},
crossDomain: true
}).done(function(data) {
// save the newly-retrieved id token
apiConfig.set('idToken', data.id_token);
}).done(success);
}
}
};
return auth;
}
);
/**
* Reusable constants
*/
define([], function() {
'use strict';
return {
keyCodes: {
tab: 9,
enter: 13,
esc: 27,
up: 38,
down: 40
}
};
});
define([
'backbone',
'backbone.validation',
'underscore',
'gettext'
],
function(Backbone, BackboneValidation, _, gettext) {
'use strict';
var errorClass = 'has-error',
messageEl = '.field-message',
messageContent = '.field-message-content';
// These are the same messages provided by Backbone.Validation,
// marked for translation.
// See: http://thedersen.com/projects/backbone-validation/#overriding-the-default-error-messages.
_.extend(Backbone.Validation.messages, {
required: gettext('{0} is required'),
acceptance: gettext('{0} must be accepted'),
min: gettext('{0} must be greater than or equal to {1}'),
max: gettext('{0} must be less than or equal to {1}'),
range: gettext('{0} must be between {1} and {2}'),
length: gettext('{0} must be {1} characters'),
minLength: gettext('{0} must be at least {1} characters'),
maxLength: gettext('{0} must be at most {1} characters'),
rangeLength: gettext('{0} must be between {1} and {2} characters'),
oneOf: gettext('{0} must be one of: {1}'),
equalTo: gettext('{0} must be the same as {1}'),
digits: gettext('{0} must only contain digits'),
number: gettext('{0} must be a number'),
email: gettext('{0} must be a valid email'),
url: gettext('{0} must be a valid url'),
inlinePattern: gettext('{0} is invalid')
});
_.extend(Backbone.Validation.callbacks, {
// Gets called when a previously invalid field in the
// view becomes valid. Removes any error message.
valid: function(view, attr, selector) {
var $input = view.$('[' + selector + '~="' + attr + '"]'),
$message = $input.siblings(messageEl);
$input.removeClass(errorClass)
.removeAttr('data-error');
$message.removeClass(errorClass)
.find(messageContent)
.text('');
},
// Gets called when a field in the view becomes invalid.
// Adds a error message.
invalid: function(view, attr, error, selector) {
var $input = view.$('[' + selector + '~="' + attr + '"]'),
$message = $input.siblings(messageEl);
$input.addClass(errorClass)
.attr('data-error', error);
$message.addClass(errorClass)
.find(messageContent)
.text($input.data('error'));
}
});
Backbone.Validation.configure({
labelFormatter: 'label'
});
}
);
define([
'backbone',
'jquery',
'underscore',
'js/programs/utils/constants',
'text!templates/programs/confirm_modal.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext'
],
function(Backbone, $, _, constants, ModalTpl, HtmlUtils) {
'use strict';
return Backbone.View.extend({
events: {
'click .js-cancel': 'destroy',
'click .js-confirm': 'confirm',
'keydown': 'handleKeydown'
},
tpl: HtmlUtils.template(ModalTpl),
initialize: function(options) {
this.$parentEl = $(options.parentEl);
this.callback = options.callback;
this.content = options.content;
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl(this.content));
HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML(this.$el));
this.postRender();
},
postRender: function() {
this.$el.find('.js-focus-first').focus();
},
confirm: function() {
this.callback();
this.destroy();
},
destroy: function() {
this.undelegateEvents();
this.remove();
this.$parentEl.html('');
},
handleKeydown: function(event) {
var keyCode = event.keyCode;
if (keyCode === constants.keyCodes.esc) {
this.destroy();
}
}
});
}
);
define([
'backbone',
'backbone.validation',
'jquery',
'underscore',
'js/programs/models/course_model',
'js/programs/models/course_run_model',
'js/programs/models/program_model',
'js/programs/views/course_run_view',
'text!templates/programs/course_details.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext',
'js/programs/utils/validation_config'
],
function(Backbone, BackboneValidation, $, _, CourseModel, CourseRunModel,
ProgramModel, CourseRunView, ListTpl, HtmlUtils) {
'use strict';
return Backbone.View.extend({
parentEl: '.js-course-list',
className: 'course-details',
events: {
'click .js-remove-course': 'destroy',
'click .js-select-course': 'setCourse',
'click .js-add-course-run': 'addCourseRun'
},
tpl: HtmlUtils.template(ListTpl),
initialize: function(options) {
this.model = new CourseModel();
Backbone.Validation.bind(this);
this.$parentEl = $(this.parentEl);
// For managing subViews
this.courseRunViews = [];
this.courseRuns = options.courseRuns;
this.programModel = options.programModel;
if (options.courseData) {
this.model.set(options.courseData);
} else {
this.model.set({run_modes: []});
}
// Need a unique value for field ids so using model cid
this.model.set({cid: this.model.cid});
this.model.on('change:run_modes', this.updateRuns, this);
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl(this.formatData()));
this.$parentEl.append(this.$el);
this.postRender();
},
postRender: function() {
var runs = this.model.get('run_modes');
if (runs && runs.length > 0) {
this.addCourseRuns();
}
},
addCourseRun: function(event) {
var $runsContainer = this.$el.find('.js-course-runs'),
runModel = new CourseRunModel(),
runView;
event.preventDefault();
runModel.set({course_key: undefined});
runView = new CourseRunView({
model: runModel,
courseModel: this.model,
courseRuns: this.courseRuns,
programStatus: this.programModel.get('status'),
$parentEl: $runsContainer
});
this.courseRunViews.push(runView);
},
addCourseRuns: function() {
// Create run views
var runs = this.model.get('run_modes'),
$runsContainer = this.$el.find('.js-course-runs');
_.each(runs, function(run) {
var runModel = new CourseRunModel(),
runView;
runModel.set(run);
runView = new CourseRunView({
model: runModel,
courseModel: this.model,
courseRuns: this.courseRuns,
programStatus: this.programModel.get('status'),
$parentEl: $runsContainer
});
this.courseRunViews.push(runView);
return runView;
}.bind(this));
},
addCourseToProgram: function() {
var courseCodes = this.programModel.get('course_codes'),
courseData = this.model.toJSON();
if (this.programModel.isValid(true)) {
// We don't want to save the cid so omit it
courseCodes.push(_.omit(courseData, 'cid'));
this.programModel.patch({course_codes: courseCodes});
}
},
// Delete this view
destroy: function() {
Backbone.Validation.unbind(this);
this.destroyChildren();
this.undelegateEvents();
this.removeCourseFromProgram();
this.remove();
},
destroyChildren: function() {
var runs = this.courseRunViews;
_.each(runs, function(run) {
run.removeRun();
});
},
// Format data to be passed to the template
formatData: function() {
var data = $.extend({},
{courseRuns: this.courseRuns.models},
_.omit(this.programModel.toJSON(), 'run_modes'),
this.model.toJSON()
);
return data;
},
removeCourseFromProgram: function() {
var courseCodes = this.programModel.get('course_codes'),
key = this.model.get('key'),
name = this.model.get('display_name'),
update = [];
update = _.reject(courseCodes, function(course) {
return course.key === key && course.display_name === name;
});
this.programModel.patch({course_codes: update});
},
setCourse: function(event) {
var $form = this.$('.js-course-form'),
title = $form.find('.display-name').val(),
key = $form.find('.course-key').val();
event.preventDefault();
this.model.set({
display_name: title,
key: key,
organization: this.programModel.get('organizations')[0]
});
if (this.model.isValid(true)) {
this.addCourseToProgram();
this.updateDOM();
this.addCourseRuns();
}
},
updateDOM: function() {
HtmlUtils.setHtml(this.$el, this.tpl(this.formatData()));
},
updateRuns: function() {
var courseCodes = this.programModel.get('course_codes'),
key = this.model.get('key'),
name = this.model.get('display_name'),
index;
if (this.programModel.isValid(true)) {
index = _.findIndex(courseCodes, function(course) {
return course.key === key && course.display_name === name;
});
courseCodes[index] = this.model.toJSON();
this.programModel.patch({course_codes: courseCodes});
}
}
});
}
);
define([
'backbone',
'jquery',
'underscore',
'text!templates/programs/course_run.underscore',
'edx-ui-toolkit/js/utils/html-utils'
],
function(Backbone, $, _, CourseRunTpl, HtmlUtils) {
'use strict';
return Backbone.View.extend({
events: {
'change .js-course-run-select': 'selectRun',
'click .js-remove-run': 'removeRun'
},
tpl: HtmlUtils.template(CourseRunTpl),
initialize: function(options) {
/**
* Need the run model for the template, and the courseModel
* to keep parent view up to date with run changes
*/
this.courseModel = options.courseModel;
this.courseRuns = options.courseRuns;
this.programStatus = options.programStatus;
this.model.on('change', this.render, this);
this.courseRuns.on('update', this.updateDropdown, this);
this.$parentEl = options.$parentEl;
this.render();
},
render: function() {
var data = this.model.attributes;
data.programStatus = this.programStatus;
if (!!this.courseRuns) {
data.courseRuns = this.courseRuns.toJSON();
}
HtmlUtils.setHtml(this.$el, this.tpl(data));
this.$parentEl.append(this.$el);
},
// Delete this view
destroy: function() {
this.undelegateEvents();
this.remove();
},
// Data returned from courseList API is not the correct format
formatData: function(data) {
return {
course_key: data.id,
mode_slug: 'verified',
start_date: data.start,
sku: ''
};
},
removeRun: function() {
// Update run_modes array on programModel
var startDate = this.model.get('start_date'),
courseKey = this.model.get('course_key'),
/**
* NB: cloning the array so the model will fire a change event when
* the updated version is saved back to the model
*/
runs = _.clone(this.courseModel.get('run_modes')),
updatedRuns = [];
updatedRuns = _.reject(runs, function(obj) {
return obj.start_date === startDate &&
obj.course_key === courseKey;
});
this.courseModel.set({
run_modes: updatedRuns
});
this.courseRuns.addRun(courseKey);
this.destroy();
},
selectRun: function(event) {
var id = $(event.currentTarget).val(),
runObj = _.findWhere(this.courseRuns.allRuns, {id: id}),
/**
* NB: cloning the array so the model will fire a change event when
* the updated version is saved back to the model
*/
runs = _.clone(this.courseModel.get('run_modes')),
data = this.formatData(runObj);
this.model.set(data);
runs.push(data);
this.courseModel.set({run_modes: runs});
this.courseRuns.removeRun(id);
},
// If a run has not been selected update the dropdown options
updateDropdown: function() {
if (!this.model.get('course_key')) {
this.render();
}
}
});
}
);
(function() {
'use strict';
define([
'backbone',
'js/programs/router',
'js/programs/utils/api_config'
],
function(Backbone, ProgramRouter, apiConfig) {
return Backbone.View.extend({
el: '.js-program-admin',
events: {
'click .js-app-click': 'navigate'
},
initialize: function() {
apiConfig.set({
lmsBaseUrl: this.$el.data('lms-base-url'),
programsApiUrl: this.$el.data('programs-api-url'),
authUrl: this.$el.data('auth-url'),
username: this.$el.data('username')
});
this.app = new ProgramRouter({
homeUrl: this.$el.data('home-url')
});
this.app.start();
},
/**
* Navigate to a new page within the app.
*
* Attempts to open the link in a new tab/window behave as the user expects, however the app
* and data will be reloaded in the new tab/window.
*
* @param {Event} event - Event being handled.
* @returns {boolean} - Indicates if event handling succeeded (always true).
*/
navigate: function(event) {
var url = $(event.target).attr('href').replace(this.app.root, '');
/**
* Handle the cases where the user wants to open the link in a new tab/window.
* event.which === 2 checks for the middle mouse button (https://api.jquery.com/event.which/)
*/
if (event.ctrlKey || event.shiftKey || event.metaKey || event.which === 2) {
return true;
}
// We'll take it from here...
event.preventDefault();
// Process the navigation in the app/router.
if (url === Backbone.history.getFragment() && url === '') {
/**
* Note: We must call the index directly since Backbone
* does not support routing to the same route.
*/
this.app.index();
} else {
this.app.navigate(url, {trigger: true});
}
}
});
}
);
})();
define([
'backbone',
'backbone.validation',
'jquery',
'underscore',
'js/programs/models/organizations_model',
'js/programs/models/program_model',
'text!templates/programs/program_creator_form.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext',
'js/programs/utils/validation_config'
],
function(Backbone, BackboneValidation, $, _, OrganizationsModel, ProgramModel, ListTpl, HtmlUtils) {
'use strict';
return Backbone.View.extend({
parentEl: '.js-program-admin',
events: {
'click .js-create-program': 'createProgram',
'click .js-abort-view': 'abort'
},
tpl: HtmlUtils.template(ListTpl),
initialize: function(options) {
this.$parentEl = $(this.parentEl);
this.model = new ProgramModel();
this.model.on('sync', this.saveSuccess, this);
this.model.on('error', this.saveError, this);
// Hook up validation.
// See: http://thedersen.com/projects/backbone-validation/#validation-binding.
Backbone.Validation.bind(this);
this.organizations = new OrganizationsModel();
this.organizations.on('sync', this.render, this);
this.organizations.fetch();
this.router = options.router;
},
render: function() {
HtmlUtils.setHtml(
this.$el,
this.tpl({
orgs: this.organizations.get('results')
})
);
HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML(this.$el));
},
abort: function(event) {
event.preventDefault();
this.router.goHome();
},
createProgram: function(event) {
var data = this.getData();
event.preventDefault();
this.model.set(data);
// Check if the model is valid before saving. Invalid attributes are looked
// up by name. The corresponding elements receieve an `invalid` class and a
// `data-error` attribute. Both are removed when formerly invalid attributes
// become valid.
// See: http://thedersen.com/projects/backbone-validation/#isvalid.
if (this.model.isValid(true)) {
this.model.save();
}
},
destroy: function() {
// Unhook validation.
// See: http://thedersen.com/projects/backbone-validation/#unbinding.
Backbone.Validation.unbind(this);
this.undelegateEvents();
this.remove();
},
getData: function() {
return {
name: this.$el.find('.program-name').val(),
subtitle: this.$el.find('.program-subtitle').val(),
category: this.$el.find('.program-type').val(),
marketing_slug: this.$el.find('.program-marketing-slug').val(),
organizations: [{
key: this.$el.find('.program-org').val()
}]
};
},
goToView: function(uri) {
Backbone.history.navigate(uri, {trigger: true});
this.destroy();
},
// TODO: add user messaging to show errors
saveError: function(jqXHR) {
console.log('saveError: ', jqXHR);
},
saveSuccess: function() {
this.goToView(String(this.model.get('id')));
}
});
}
);
define([
'backbone',
'backbone.validation',
'jquery',
'underscore',
'js/programs/collections/course_runs_collection',
'js/programs/models/program_model',
'js/programs/views/confirm_modal_view',
'js/programs/views/course_details_view',
'text!templates/programs/program_details.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext',
'js/programs/utils/validation_config'
],
function(Backbone, BackboneValidation, $, _, CourseRunsCollection,
ProgramModel, ModalView, CourseView, ListTpl,
HtmlUtils) {
'use strict';
return Backbone.View.extend({
el: '.js-program-admin',
events: {
'blur .js-inline-edit input': 'checkEdit',
'click .js-add-course': 'addCourse',
'click .js-enable-edit': 'editField',
'click .js-publish-program': 'confirmPublish'
},
tpl: HtmlUtils.template(ListTpl),
initialize: function() {
Backbone.Validation.bind(this);
this.courseRuns = new CourseRunsCollection([], {
organization: this.model.get('organizations')[0]
});
this.courseRuns.fetch();
this.courseRuns.on('sync', this.setAvailableCourseRuns, this);
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl(this.model.toJSON()));
this.postRender();
},
postRender: function() {
var courses = this.model.get('course_codes');
_.each(courses, function(course) {
var title = course.key + 'Course';
this[title] = new CourseView({
courseRuns: this.courseRuns,
programModel: this.model,
courseData: course
});
}.bind(this));
// Stop listening to the model sync set when publishing
this.model.off('sync');
},
addCourse: function() {
return new CourseView({
courseRuns: this.courseRuns,
programModel: this.model
});
},
checkEdit: function(event) {
var $input = $(event.target),
$span = $input.prevAll('.js-model-value'),
$btn = $input.next('.js-enable-edit'),
value = $input.val(),
key = $input.data('field'),
data = {};
data[key] = value;
$input.addClass('is-hidden');
$btn.removeClass('is-hidden');
$span.removeClass('is-hidden');
if (this.model.get(key) !== value) {
this.model.set(data);
if (this.model.isValid(true)) {
this.model.patch(data);
$span.text(value);
}
}
},
/**
* Loads modal that user clicks a confirmation button
* in to publish the course (or they can cancel out of it)
*/
confirmPublish: function(event) {
event.preventDefault();
/**
* Update validation to make marketing slug required
* Note that because this validation is not required for
* the program creation form and is only happening here
* it makes sense to have the validation at the view level
*/
if (this.model.isValid(true) && this.validateMarketingSlug()) {
this.modalView = new ModalView({
model: this.model,
callback: _.bind(this.publishProgram, this),
content: this.getModalContent(),
parentEl: '.js-publish-modal',
parentView: this
});
}
},
editField: function(event) {
/**
* Making the assumption that users can only see
* programs that they have permission to edit
*/
var $btn = $(event.currentTarget),
$el = $btn.prev('input');
event.preventDefault();
$el.prevAll('.js-model-value').addClass('is-hidden');
$el.removeClass('is-hidden')
.addClass('edit')
.focus();
$btn.addClass('is-hidden');
},
getModalContent: function() {
return {
name: gettext('confirm'),
title: gettext('Publish this program?'),
body: gettext(
'After you publish this program, you cannot add or remove course codes or remove course runs.'
),
cta: {
cancel: gettext('Cancel'),
confirm: gettext('Publish')
}
};
},
publishProgram: function() {
var data = {
status: 'active'
};
this.model.set(data, {silent: true});
this.model.on('sync', this.render, this);
this.model.patch(data);
},
setAvailableCourseRuns: function() {
var allRuns = this.courseRuns.toJSON(),
courses = this.model.get('course_codes'),
selectedRuns,
availableRuns = allRuns;
if (courses.length) {
selectedRuns = _.pluck(courses, 'run_modes');
selectedRuns = _.flatten(selectedRuns);
}
availableRuns = _.reject(allRuns, function(run) {
var selectedCourseRun = _.findWhere(selectedRuns, {
course_key: run.id,
start_date: run.start
});
return !_.isUndefined(selectedCourseRun);
});
this.courseRuns.set(availableRuns);
},
validateMarketingSlug: function() {
var isValid = false,
$input = {},
$message = {};
if (this.model.get('marketing_slug').length > 0) {
isValid = true;
} else {
$input = this.$el.find('#program-marketing-slug');
$message = $input.siblings('.field-message');
// Update DOM
$input.addClass('has-error');
$message.addClass('has-error');
$message.find('.field-message-content')
.text(gettext('Marketing Slug is required.'));
}
return isValid;
}
});
}
);
define([
'underscore',
'backbone',
'jquery',
'js/programs/utils/api_config',
'js/programs/models/auto_auth_model'
],
function(_, Backbone, $, apiConfig, AutoAuthModel) {
'use strict';
describe('AutoAuthModel', function() {
var model,
testErrorCallback,
fakeAjaxDeferred,
spyOnBackboneSync,
callSync,
checkAuthAttempted,
dummyModel = {'dummy': 'model'},
authUrl = apiConfig.get('authUrl');
beforeEach(function() {
// instance under test
model = new AutoAuthModel();
// stand-in for the error callback a caller might pass with options to Backbone.Model.sync
testErrorCallback = jasmine.createSpy();
fakeAjaxDeferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(fakeAjaxDeferred);
return fakeAjaxDeferred;
});
spyOnBackboneSync = function(status) {
// set up Backbone.sync to invoke its error callback with the desired HTTP status
spyOn(Backbone, 'sync').and.callFake(function(method, model, options) {
var fakeXhr = options.xhr = {status: status};
options.error(fakeXhr, 0, '');
});
};
callSync = function(options) {
var params,
syncOptions = _.extend({error: testErrorCallback}, options || {});
model.sync('GET', dummyModel, syncOptions);
// make sure Backbone.sync was called with custom error handling
expect(Backbone.sync.calls.count()).toEqual(1);
params = _.object(['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args);
expect(params.method).toEqual('GET');
expect(params.model).toEqual(dummyModel);
expect(params.options.error).not.toEqual(testErrorCallback);
return params;
};
checkAuthAttempted = function(isExpected) {
if (isExpected) {
expect($.ajax).toHaveBeenCalled();
expect($.ajax.calls.mostRecent().args[0].url).toEqual(authUrl);
} else {
expect($.ajax).not.toHaveBeenCalled();
}
};
it('should exist', function() {
expect(model).toBeDefined();
});
it('should intercept 401 errors and attempt auth', function() {
var callParams;
spyOnBackboneSync(401);
callSync();
// make sure the auth attempt was initiated
checkAuthAttempted(true);
// fire the success handler for the fake ajax call, with id token response data
fakeAjaxDeferred.resolve({id_token: 'test-id-token'});
// make sure the original request was retried with token, and without custom error handling
expect(Backbone.sync.calls.count()).toEqual(2);
callParams = _.object(['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args);
expect(callParams.method).toEqual('GET');
expect(callParams.model).toEqual(dummyModel);
expect(callParams.options.error).toEqual(testErrorCallback);
expect(callParams.options.headers.Authorization).toEqual('JWT test-id-token');
});
it('should not intercept non-401 errors', function() {
spyOnBackboneSync(403);
// invoke AutoAuthModel.sync
callSync();
// make sure NO auth attempt was initiated
checkAuthAttempted(false);
// make sure the original request was not retried
expect(Backbone.sync.calls.count()).toEqual(1);
// make sure the default error handling was invoked
expect(testErrorCallback).toHaveBeenCalled();
});
});
}
);
define([
'backbone',
'jquery',
'js/programs/views/program_creator_view'
],
function(Backbone, $, ProgramCreatorView) {
'use strict';
describe('ProgramCreatorView', function() {
var view = {},
Router = Backbone.Router.extend({
initialize: function(options) {
this.homeUrl = options.homeUrl;
},
goHome: function() {
window.location.href = this.homeUrl;
}
}),
organizations = {
count: 1,
previous: null,
'num_pages': 1,
results: [{
'display_name': 'test-org-display_name',
'key': 'test-org-key'
}],
next: null
},
sampleInput,
completeForm = function(data) {
view.$el.find('#program-name').val(data.name);
view.$el.find('#program-subtitle').val(data.subtitle);
view.$el.find('#program-org').val(data.organizations);
if (data.category) {
view.$el.find('#program-type').val(data.category);
}
if (data.marketing_slug) {
view.$el.find('#program-marketing-slug').val(data.marketing_slug);
}
},
verifyValidation = function(data, invalidAttr) {
var errorClass = 'has-error',
$invalidElement = view.$el.find('[name="' + invalidAttr + '"]'),
$errorMsg = $invalidElement.siblings('.field-message'),
inputErrorMsg = '';
completeForm(data);
view.$el.find('.js-create-program').click();
inputErrorMsg = $invalidElement.data('error');
expect(view.model.save).not.toHaveBeenCalled();
expect($invalidElement).toHaveClass(errorClass);
expect($errorMsg).toHaveClass(errorClass);
expect(inputErrorMsg).toBeDefined();
expect($errorMsg.find('.field-message-content').html()).toEqual(inputErrorMsg);
};
var validateFormSubmitted = function(view, programId) {
expect($.ajax).toHaveBeenCalled();
expect(view.saveSuccess).toHaveBeenCalled();
expect(view.goToView).toHaveBeenCalledWith(String(programId));
expect(view.saveError).not.toHaveBeenCalled();
};
beforeEach(function() {
// Set the DOM
setFixtures('<div class="js-program-admin"></div>');
jasmine.clock().install();
spyOn(ProgramCreatorView.prototype, 'saveSuccess').and.callThrough();
spyOn(ProgramCreatorView.prototype, 'goToView').and.callThrough();
spyOn(ProgramCreatorView.prototype, 'saveError').and.callThrough();
spyOn(Router.prototype, 'goHome');
sampleInput = {
category: 'XSeries',
organizations: 'test-org-key',
name: 'Test Course Name',
subtitle: 'Test Course Subtitle',
marketing_slug: 'test-management'
};
view = new ProgramCreatorView({
router: new Router({
homeUrl: '/author/home'
})
});
view.organizations.set(organizations);
view.render();
});
afterEach(function() {
view.destroy();
jasmine.clock().uninstall();
});
it('should exist', function() {
expect(view).toBeDefined();
});
it('should get the form data', function() {
var formData = {};
completeForm(sampleInput);
formData = view.getData();
expect(formData.name).toEqual(sampleInput.name);
expect(formData.subtitle).toEqual(sampleInput.subtitle);
expect(formData.organizations[0].key).toEqual(sampleInput.organizations);
});
it('should submit the form when the user clicks submit', function() {
var programId = 123;
completeForm(sampleInput);
spyOn($, 'ajax').and.callFake(function(event) {
event.success({id: programId});
});
view.$el.find('.js-create-program').click();
validateFormSubmitted(view, programId);
});
it('should submit the form correctly when creating micromasters program ', function() {
var programId = 221;
sampleInput.category = 'MicroMasters';
completeForm(sampleInput);
spyOn($, 'ajax').and.callFake(function(event) {
event.success({id: programId});
});
view.$el.find('.js-create-program').click();
validateFormSubmitted(view, programId);
});
it('should run the saveError when model save failures occur', function() {
spyOn($, 'ajax').and.callFake(function(event) {
event.error();
});
// Fill out the form with valid data so that form model validation doesn't
// prevent the model from being saved.
completeForm(sampleInput);
view.$el.find('.js-create-program').click();
expect($.ajax).toHaveBeenCalled();
expect(view.saveSuccess).not.toHaveBeenCalled();
expect(view.saveError).toHaveBeenCalled();
});
it('should set the model when valid form data is submitted', function() {
completeForm(sampleInput);
spyOn($, 'ajax').and.callFake(function(event) {
event.success({id: 10001110101});
});
view.$el.find('.js-create-program').click();
expect(view.model.get('name')).toEqual(sampleInput.name);
expect(view.model.get('subtitle')).toEqual(sampleInput.subtitle);
expect(view.model.get('organizations')[0].key).toEqual(sampleInput.organizations);
expect(view.model.get('marketing_slug')).toEqual(sampleInput.marketing_slug);
});
it('should not set the model when bad program type selected', function() {
var invalidInput = $.extend({}, sampleInput);
spyOn(view.model, 'save');
// No name provided.
invalidInput.category = '';
verifyValidation(invalidInput, 'category');
// bad program type name
invalidInput.name = 'badprogramtype';
verifyValidation(invalidInput, 'category');
});
it('should not set the model when an invalid program name is submitted', function() {
var invalidInput = $.extend({}, sampleInput);
spyOn(view.model, 'save');
// No name provided.
invalidInput.name = '';
verifyValidation(invalidInput, 'name');
// Name is too long.
invalidInput.name = 'x'.repeat(256);
verifyValidation(invalidInput, 'name');
});
it('should not set the model when an invalid program subtitle is submitted', function() {
var invalidInput = $.extend({}, sampleInput);
spyOn(view.model, 'save');
// Subtitle is too long.
invalidInput.subtitle = 'x'.repeat(300);
verifyValidation(invalidInput, 'subtitle');
});
it('should not set the model when an invalid category is submitted', function() {
var invalidInput = $.extend({}, sampleInput);
spyOn(view.model, 'save');
// Category other than 'xseries' selected.
invalidInput.category = 'yseries';
verifyValidation(invalidInput, 'category');
});
it('should not set the model when an invalid organization key is submitted', function() {
var invalidInput = $.extend({}, sampleInput);
spyOn(view.model, 'save');
// No organization selected.
invalidInput.organizations = 'false';
verifyValidation(invalidInput, 'organizations');
});
it('should not set the model when an invalid marketing slug is submitted', function() {
var invalidInput = $.extend({}, sampleInput);
spyOn(view.model, 'save');
// Marketing slug is too long.
invalidInput.marketing_slug = 'x'.repeat(256);
verifyValidation(invalidInput, 'marketing_slug');
});
it('should abort the view when the cancel button is clicked', function() {
completeForm(sampleInput);
expect(view.$parentEl.html().length).toBeGreaterThan(0);
view.$el.find('.js-abort-view').click();
expect(view.router.goHome).toHaveBeenCalled();
});
});
}
);
......@@ -16,4 +16,3 @@
@import 'elements/footer';
@import 'elements-v2/sock';
@import 'elements-v2/tooltip';
@import 'programs/build';
// ------------------------------
// Programs: App Container
// About: styling for setting up the wrapper.
.program-app {
&.layout-1q3q {
max-width: 1250px;
// HtmlUtils.template wraps children of this in an anonymous div. We need to make sure they render at full width
& > div {
@include span(12);
}
}
}
// ------------------------------
// Programs: Main Style Compile
// About: Sass compile for the Programs IDA.
@import 'components';
@import 'views';
@import 'modals';
@import 'app-container';
// ------------------------------
// Programs: Components
// About: styling for specific UI components ranging from global to modular.
// #BUTTONS
// #FORMS
// ------------------------------
// #BUTTONS
// ------------------------------
.btn {
&.btn-delete,
&.btn-edit {
border: none;
background: none;
color: palette(grayscale, base);
&:hover,
&:focus,
&:active {
color: $black;
}
}
&.full {
width: 100%;
}
&.right {
@include float(right);
}
&.btn-create {
background: palette(success, base);
border-color: palette(success, base);
// STATE: hover and focus
&:hover,
&.is-hovered,
&:focus,
&.is-focused {
background: shade($success, 33%);
color: palette(primary, accent);
}
// STATE: is pressed or active
&:active,
&.is-pressed,
&.is-active {
border-color: shade($success, 33%);
background: shade($success, 33%);
}
.text {
margin-left: 5px;
}
}
.icon,
.text {
vertical-align: middle;
}
.icon {
font-size: 16px;
}
}
// ------------------------------
// #FORMS
// ------------------------------
.field {
.invalid {
border: 2px solid palette(error, base);
}
.field-input,
.field-hint,
.field-message {
min-with: 300px;
width: 50%;
&.is-hidden {
@extend .is-hidden;
}
}
.copy {
vertical-align: middle;
}
}
.form-group {
&.bg-white {
background-color: $white;
}
}
// ------------------------------
// Programs: Modals
// About: styling for modals.
.modal-window-overlay {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: palette(grayscale, dark);
opacity: 0.5;
z-index: 1000;
}
.modal-window {
position: absolute;
background-color: $black;
width: 80%;
left: 10%;
top: 40%;
z-index: 1001;
}
.modal-content {
margin: 5px;
padding: 20px;
background-color: palette(grayscale, dark);
border-top: 5px solid palette(warning, accent);
.copy {
color: $white;
}
.emphasized {
color: $white;
font-weight: font-weight(bold);
}
}
.modal-actions {
padding: 10px 20px;
.btn {
color: palette(grayscale, back);
}
.btn-brand {
background: palette(warning, back);
color: palette(grayscale, dark);
border-color: palette(warning, accent);
&:hover,
&:focus,
&:active {
background: palette(warning, back);
border-color: palette(warning, accent);
}
}
.btn-neutral {
background: transparent;
border-color: transparent;
&:hover,
&:focus,
&:active {
border-color: palette(grayscale, back)
}
}
}
@include breakpoint( $bp-screen-sm ) {
.modal-window {
width: 440px;
left: calc( 50% - 220px );
}
}
// ------------------------------
// Programs: Views
// About: styling for specific views.
// ------------------------------
// #PROGRAM LISTS
// ------------------------------
.program-list {
list-style-type: none;
padding-left: 0;
.program-details {
.name {
font-size: 2rem;
}
.status {
@include float(right);
}
.category {
color: palette(grayscale, base);
}
}
}
.app-header {
@include clearfix();
@include grid-row;
border-bottom: 1px solid palette(grayscale, base);
margin-bottom: 20px;
form {
@include span(12);
}
}
.course-container {
.subtitle {
color: palette(grayscale, base);
}
}
.run-container {
position: relative;
margin: {
bottom: 20px;
};
&:before {
content: '';
width: 5px;
height: calc( 100% + 1px );
background: palette(grayscale, base);
position: absolute;
top: 0;
left: 0;
}
}
.course-container {
margin: {
bottom: 20px;
};
}
......@@ -12,4 +12,3 @@ $pattern-library-path: '../edx-pattern-library' !default;
// Load the shared build
@import 'build-v2';
@import 'programs/build';
......@@ -55,7 +55,7 @@
}
.action-create-course, .action-create-library, .action-create-program {
.action-create-course, .action-create-library {
@extend %btn-primary-green;
@extend %t-action3;
}
......@@ -318,7 +318,7 @@
}
// ELEM: course listings
.courses-tab, .libraries-tab, .programs-tab {
.courses-tab, .libraries-tab {
display: none;
&.active {
......@@ -326,7 +326,7 @@
}
}
.courses, .libraries, .programs {
.courses, .libraries {
.title {
@extend %t-title6;
margin-bottom: $baseline;
......
......@@ -38,11 +38,6 @@ from openedx.core.djangolib.markup import HTML, Text
<a href="#" class="button new-button new-library-button"><span class="icon fa fa-plus icon-inline" aria-hidden="true"></span>
${_("New Library")}</a>
% endif
% if is_programs_enabled:
<a href=${program_authoring_url + 'new'} class="button new-button new-program-button"><span class="icon fa fa-plus icon-inline" aria-hidden="true"></span>
${_("New Program")}</a>
% endif
</li>
</ul>
</nav>
......@@ -295,17 +290,10 @@ from openedx.core.djangolib.markup import HTML, Text
</div>
%endif
% if libraries_enabled or is_programs_enabled:
% if libraries_enabled:
<ul id="course-index-tabs">
<li class="courses-tab active"><a>${_("Courses")}</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
<li class="libraries-tab"><a>${_("Libraries")}</a></li>
</ul>
% endif
......@@ -516,52 +504,6 @@ from openedx.core.djangolib.markup import HTML, Text
</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">
<a class="program-link" href=${program_authoring_url + str(program['id'])}>
<h3 class="course-title">${program['name']}</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']}</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">
<a href=${program_authoring_url + 'new'} class="action-primary action-create new-button action-create-program new-program-button"><span class="icon fa fa-plus icon-inline" aria-hidden="true"></span> ${_('Create Your First Program')}</a>
</li>
</ul>
</div>
</div>
% endif
% endif
</article>
<aside class="content-supplementary" role="complementary">
<div class="bit">
......
<div class="wrapper wrapper-modal-window wrapper-modal-window-<%- name %>"
aria-describedby="modal-window-description"
aria-labelledby="modal-window-title"
aria-hidden=""
role="dialog">
<div class="modal-window-overlay"></div>
<div class="js-focus-first modal-window modal-medium modal-type-confirm" tabindex="-1" aria-labelledby="modal-window-title">
<div class="<%- name %>-modal">
<div class="modal-content">
<span class="copy copy-lead emphasized"><%- title %></span>
<p class="copy copy-base"><%- body %></p>
</div>
<div class="modal-actions">
<h3 class="sr-only"><%- gettext('Actions') %></h3>
<button class="js-confirm btn btn-brand btn-base">
<span><%- cta.confirm %></span>
</button>
<button class="js-cancel btn btn-neutral btn-base">
<span><%- cta.cancel %></span>
</button>
</div>
</div>
</div>
</div>
<div class="card course-container">
<% if ( display_name ) { %>
<span class="copy copy-large emphasized"><%- display_name %></span>
<% if ( status === 'unpublished' ) { %>
<button class="js-remove-course btn btn-delete right" data-tooltip="<%- gettext('Delete course') %>">
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
<span class="sr-only"><%- interpolate(
gettext('Remove %(name)s from the program'),
{ name: display_name },
true
) %></span>
</button>
<% } %>
<p class="copy copy-base subtitle"><%- organization.display_name %> / <%- key %>
<div class="js-course-runs"></div>
<% if ( courseRuns.length > -1 ) { %>
<button class="js-add-course-run btn btn-neutral btn-base full">
<span class="icon fa fa-plus" aria-hidden="true"></span>
<span class="text"><%- gettext('Add another run') %></span>
</button>
<% } %>
<% } else { %>
<form class="form js-course-form">
<fieldset class="form-group">
<div class="field">
<label class="field-label" for="course-key-<%- cid %>"><%- gettext('Course Code') %></label>
<input id="course-key-<%- cid %>" class="field-input input-text course-key" name="key" aria-describedby="course-key-<%- cid %>-desc" maxlength="255" required>
<div class="field-message">
<span class="field-message-content"></span>
</div>
<div class="field-hint" id="course-key-<%- cid %>-desc">
<p><%- gettext('The unique number that identifies your course within your organization, e.g. CS101.') %></p>
</div>
</div>
<div class="field">
<label class="field-label" for="display-name-<%- cid %>"><%- gettext('Course Title') %></label>
<input id="display-name-<%- cid %>" class="field-input input-text display-name" name="display_name" aria-describedby="display-name-<%- cid %>-desc" maxlength="255" required>
<div class="field-message">
<span class="field-message-content"></span>
</div>
<div class="field-hint" id="display-name-<%- cid %>-desc">
<p><%- gettext('The title entered here will override the title set for the individual run of the course. It will be displayed on the XSeries progress page and in marketing presentations.') %></p>
</div>
</div>
<button class="btn btn-primary js-select-course"><%- gettext('Save Course') %></button>
</fieldset>
</form>
<% } %>
</div>
<div class="card run-container">
<% if ( !_.isUndefined(course_key) ) { %>
<span class="copy copy-large emphasized"><%- interpolate(
gettext('Run %(key)s'),
{ key: course_key },
true
) %></span>
<% if ( programStatus === 'unpublished' ) { %>
<button class="js-remove-run btn btn-delete right" data-tooltip="<%- gettext('Delete course run') %>">
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
<span class="sr-only"><%- interpolate(
gettext('Remove run %(key)s from the program'),
{ key: course_key },
true
) %></span>
</button>
<% } %>
<div class="copy copy-base subtitle"><%- interpolate(
gettext('Start Date: %(date)s'),
{ date: start_date },
true
) %></div>
<div class="copy copy-base subtitle"><%- interpolate(
gettext('Mode: %(mode)s'),
{ mode: mode_slug },
true
) %></div>
<% } else { %>
<select class="js-course-run-select">
<option><%- gettext('Please select a Course Run') %></option>
<% _.each(courseRuns, function(run) { %>
<option value="<%- run.id %>"><%- run.name %>: <%- run.id %></option>
<% }); %>
</select>
<% } %>
</div>
<h3 class="hd-3 emphasized"><%- gettext('Create a New Program') %></h3>
<form class="form">
<fieldset class="form-group bg-white">
<div class="field">
<label class="field-label" for="program-type"><%- gettext('Program type') %></label>
<select id="program-type" class="field-input input-select program-type" name="category">
<option value=""><%- gettext('Select a type') %></option>
<option value="XSeries"><%- gettext('XSeries') %></option>
<option value="MicroMasters"><%- gettext('MicroMasters') %></option>
</select>
<div class="field-message">
<span class="field-message-content"></span>
</div>
</div>
<div class="field">
<label class="field-label" for="program-org"><%- gettext('Organization') %></label>
<select id="program-org" class="field-input input-select program-org" name="organizations">
<option value="false"><%- gettext('Select an organization') %></option>
<% _.each( orgs, function( org ) { %>
<option value="<%- org.key %>"><%- org.display_name %></option>
<% }); %>
</select>
<div class="field-message">
<span class="field-message-content"></span>
</div>
</div>
<div class="field">
<label class="field-label" for="program-name"><%- gettext('Name') %></label>
<input id="program-name" class="field-input input-text program-name" name="name" maxlength="64" aria-describedby="program-name-desc" required>
<div class="field-message">
<span class="field-message-content"></span>
</div>
<div class="field-hint" id="program-name-desc">
<p><%- gettext('The public display name of the program.') %></p>
</div>
</div>
<div class="field">
<label class="field-label" for="program-subtitle"><%- gettext('Subtitle') %></label>
<input id="program-subtitle" class="field-input input-text program-subtitle" name="subtitle" maxlength="255" aria-describedby="program-subtitle-desc">
<div class="field-message">
<span class="field-message-content"></span>
</div>
<div class="field-hint" id="program-subtitle-desc">
<p><%- gettext('A short description of the program, including concepts covered and expected outcomes (255 character limit).') %></p>
</div>
</div>
<div class="field">
<label class="field-label" for="program-marketing-slug"><%- gettext('Marketing Slug') %></label>
<input id="program-marketing-slug" class="field-input input-text program-marketing-slug" name="marketing_slug" maxlength="255" aria-describedby="program-marketing-slug-desc">
<div class="field-message">
<span class="field-message-content"></span>
</div>
<div class="field-hint" id="program-marketing-slug-desc">
<p><%- gettext('Slug used to generate links to the marketing site.') %></p>
</div>
</div>
<div class="field">
<button class="btn btn-brand btn-base js-create-program"><%- gettext('Create') %></button>
<button class="btn btn-neutral btn-base js-abort-view"><%- gettext('Cancel') %></button>
</div>
</fieldset>
</form>
<header class="app-header">
<form class="layout layout-1q3q">
<div class="layout-col layout-col-b">
<div class="js-inline-edit field">
<span class="js-model-value copy copy-large emphasized"><%- name %></span>
<label for="program-name" class="sr-only"><%- gettext('Name') %></label>
<input type="text" value="<%- name %>" id="program-name" class="program-name field-input is-hidden" name="name" data-field="name" maxlength="64" required>
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program title') %>">
<span class="icon fa fa-pencil" aria-hidden="true"></span>
<span class="sr-only"><%- gettext('Edit the program\'s name.') %></span>
</button>
<div class="field-message">
<span class="field-message-content"></span>
</div>
</div>
<div class="js-inline-edit field">
<span class="js-model-value copy copy-base subtitle"><%- subtitle %></span>
<label for="program-subtitle" class="sr-only"><%- gettext('Subtitle') %></label>
<input type="text" value="<%- subtitle %>" id="program-subtitle" class="program-subtitle field-input is-hidden" name="subtitle" data-field="subtitle" maxlength="255">
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program subtitle') %>">
<span class="icon fa fa-pencil" aria-hidden="true"></span>
<span class="sr-only"><%- gettext('Edit the program\'s subtitle.') %></span>
</button>
<div class="field-message">
<span class="field-message-content"></span>
</div>
</div>
<div class="js-inline-edit field">
<span class="js-model-value copy copy-base subtitle"><%- marketing_slug %></span>
<label for="program-subtitle" class="sr-only"><%- gettext('Marketing Slug') %></label>
<input type="text" value="<%- marketing_slug %>" id="program-marketing-slug" class="program-marketing-slug field-input is-hidden" name="marketing_slug" data-field="marketing_slug" maxlength="255">
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program marketing slug') %>">
<span class="icon fa fa-pencil" aria-hidden="true"></span>
<span class="sr-only"><%- gettext('Edit the program\'s marketing slug.') %></span>
</button>
<div class="field-message">
<span class="field-message-content"></span>
</div>
</div>
</div>
<div class="layout-col layout-col-a">
<% if ( status === 'unpublished' ) { %>
<button class="js-publish-program btn btn-neutral btn-base btn-grey right">
<span><%- gettext('Publish') %></span>
</button>
<% } %>
</div>
</form>
</header>
<div class="layout-col layout-col-b">
<div class="js-course-list"></div>
<% if ( status === 'unpublished' ) { %>
<button class="js-add-course btn btn-neutral btn-base full">
<span class="icon fa fa-plus" aria-hidden="true"></span>
<span class="text"><%- gettext('Add a course') %></span>
</button>
<% } %>
</div>
<aside class="js-aside layout-col layout-col-a"></aside>
<div class="js-publish-modal"></div>
<%page expression_filter="h"/>
<%namespace name='static' file='static_content.html'/>
<%!
from django.utils.translation import ugettext as _
from openedx.core.djangolib.js_utils import js_escaped_string
%>
<%inherit file="base.html" />
<%block name="title">${_("Program Administration")}</%block>
<%block name="header_extras">
<%! main_css = "style-main-v2" %>
</%block>
<%block name="requirejs">
require(["js/programs/program_admin_app"], function () {});
</%block>
<%block name="content">
<div class="js-program-admin program-app layout-1q3q layout" data-home-url="${studio_home_url}" data-lms-base-url="${lms_base_url}" data-programs-api-url="${programs_api_url}" data-auth-url="${programs_token_url}" data-username="${request.user.username}"></div>
</%block>
......@@ -180,11 +180,6 @@
</li>
</ol>
</nav>
% elif show_programs_header:
<h2 class="info-course">
<span class="course-org">${settings.PLATFORM_NAME}</span><span class="course-number">${_("Programs")}</span>
<span class="course-title">${_("Program Administration")}</span>
</h2>
% endif
</div>
......
......@@ -3,7 +3,6 @@ from django.conf.urls import patterns, include, url
# There is a course creators admin table.
from ratelimitbackend import admin
from cms.djangoapps.contentstore.views.program import ProgramAuthoringView, ProgramsIdTokenView
from cms.djangoapps.contentstore.views.organization import OrganizationListView
admin.autodiscover()
......@@ -178,14 +177,6 @@ urlpatterns += patterns(
url(r'^maintenance/', include('maintenance.urls', namespace='maintenance')),
)
urlpatterns += (
# These views use a configuration model to determine whether or not to
# display the Programs authoring app. If disabled, a 404 is returned.
url(r'^programs/id_token/$', ProgramsIdTokenView.as_view(), name='programs_id_token'),
# Drops into the Programs authoring app, which handles its own routing.
url(r'^program/', ProgramAuthoringView.as_view(), name='programs'),
)
if settings.DEBUG:
try:
from .urls_dev import urlpatterns as dev_urlpatterns
......
......@@ -276,57 +276,3 @@ class HomePage(DashboardPage):
Home page for Studio when logged in.
"""
url = BASE_URL + "/home/"
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 a.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 a.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
def click_new_program_button(self):
"""
Click on the new program button.
"""
self.q(css='.button.new-button.new-program-button').click()
self.wait_for_ajax()
self.wait_for_element_visibility(".account-username", "New program page is open")
......@@ -7,7 +7,7 @@ from unittest import skip
from common.test.acceptance.fixtures.course import XBlockFixtureDesc
from common.test.acceptance.tests.studio.base_studio_test import StudioCourseTest, ContainerBase
from common.test.acceptance.pages.studio.index import DashboardPage, DashboardPageWithPrograms
from common.test.acceptance.pages.studio.index import DashboardPage
from common.test.acceptance.pages.studio.utils import click_studio_help, studio_help_links
from common.test.acceptance.pages.studio.index import IndexPage, HomePage
from common.test.acceptance.tests.studio.base_studio_test import StudioLibraryTest
......@@ -26,7 +26,6 @@ from common.test.acceptance.pages.studio.settings_advanced import AdvancedSettin
from common.test.acceptance.pages.studio.settings_certificates import CertificatesPage
from common.test.acceptance.pages.studio.import_export import ExportCoursePage, ImportCoursePage
from common.test.acceptance.pages.studio.users import CourseTeamPage
from common.test.acceptance.fixtures.programs import ProgramsConfigMixin
from common.test.acceptance.tests.helpers import (
AcceptanceTest,
assert_nav_help_link,
......@@ -528,40 +527,6 @@ class LibraryExportHelpTest(StudioLibraryTest):
@attr(shard=10)
class NewProgramHelpTest(ProgramsConfigMixin, AcceptanceTest):
"""
Test help links on a 'New Program' page
"""
def setUp(self):
super(NewProgramHelpTest, self).setUp()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.program_page = DashboardPageWithPrograms(self.browser)
self.auth_page.visit()
self.set_programs_api_configuration(True)
self.program_page.visit()
def test_program_create_nav_help(self):
"""
Scenario: Help link in navigation bar is working on 'New Program' page
Given that I am on the 'New Program' page
And I want help about the process
And I click the 'Help' in the navigation bar
Then Help link should open.
And help url should end with 'index.html'
"""
self.program_page.click_new_program_button()
href = 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course' \
'/en/latest/index.html'
# Assert that help link is correct.
assert_nav_help_link(
test=self,
page=self.program_page,
href=href,
)
@attr(shard=10)
class CourseOutlineHelpTest(StudioCourseTest):
"""
Tests help links on course outline page.
......
......@@ -5,18 +5,15 @@ from flaky import flaky
from opaque_keys.edx.locator import LibraryLocator
from uuid import uuid4
from common.test.acceptance.fixtures.catalog import CatalogFixture, CatalogConfigMixin
from common.test.acceptance.fixtures.programs import ProgramsFixture, ProgramsConfigMixin
from common.test.acceptance.pages.studio.auto_auth import AutoAuthPage
from common.test.acceptance.pages.studio.library import LibraryEditPage
from common.test.acceptance.pages.studio.index import DashboardPage, DashboardPageWithPrograms
from common.test.acceptance.pages.studio.index import DashboardPage
from common.test.acceptance.pages.lms.account_settings import AccountSettingsPage
from common.test.acceptance.tests.helpers import (
AcceptanceTest,
select_option_by_text,
get_selected_option_text
)
from openedx.core.djangoapps.programs.tests import factories
class CreateLibraryTest(AcceptanceTest):
......@@ -68,98 +65,6 @@ class CreateLibraryTest(AcceptanceTest):
self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
class DashboardProgramsTabTest(ProgramsConfigMixin, CatalogConfigMixin, AcceptanceTest):
"""
Test the programs tab on the studio home page.
"""
def setUp(self):
super(DashboardProgramsTabTest, self).setUp()
self.stub_programs_api()
self.stub_catalog_api()
self.auth_page = AutoAuthPage(self.browser, staff=True)
self.dashboard_page = DashboardPageWithPrograms(self.browser)
self.auth_page.visit()
def stub_programs_api(self):
"""Stub out the programs API with fake data."""
self.set_programs_api_configuration(is_enabled=True)
ProgramsFixture().install_programs([])
def stub_catalog_api(self):
"""Stub out the catalog API's program endpoint."""
self.set_catalog_configuration(is_enabled=True)
CatalogFixture().install_programs([])
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.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')]
programs = [
factories.Program(
name=name,
organizations=[
factories.Organization(key=org),
],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(),
]),
]
)
for name, org in test_program_values
]
ProgramsFixture().install_programs(programs)
self.dashboard_page.visit()
self.assertTrue(self.dashboard_page.is_programs_tab_present())
self.assertTrue(self.dashboard_page.is_new_program_button_present())
self.assertFalse(self.dashboard_page.is_empty_list_create_button_present())
results = self.dashboard_page.get_program_list()
self.assertEqual(results, test_program_values)
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.
"""
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())
class StudioLanguageTest(AcceptanceTest):
""" Test suite for the Studio Language """
def setUp(self):
......
......@@ -5,8 +5,10 @@ import mock
from nose.plugins.attrib import attr
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangolib.testing.utils import skip_unless_lms
@skip_unless_lms
@attr(shard=2)
@ddt.ddt
# ConfigurationModels use the cache. Make every cache get a miss.
......
......@@ -12,7 +12,7 @@ from provider.constants import CONFIDENTIAL
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.tests.factories import UserFactory
......@@ -22,6 +22,7 @@ TEST_API_URL = 'http://www-internal.example.com/api'
TEST_API_SIGNING_KEY = 'edx'
@skip_unless_lms
@attr(shard=2)
@httpretty.activate
class TestGetEdxApiData(ProgramsApiConfigMixin, CacheIsolationTestCase):
......
......@@ -4,7 +4,6 @@
"dependencies": {
"backbone": "~1.3.2",
"backbone.paginator": "~2.0.3",
"backbone-validation": "~0.11.5",
"coffee-script": "1.6.1",
"edx-pattern-library": "0.18.0",
"edx-ui-toolkit": "1.5.1",
......
......@@ -47,7 +47,6 @@ COMMON_LOOKUP_PATHS = [
# A list of NPM installed libraries that should be copied into the common
# static directory.
NPM_INSTALLED_LIBRARIES = [
'backbone-validation/dist/backbone-validation-min.js',
'backbone/backbone.js',
'backbone.paginator/lib/backbone.paginator.js',
'moment-timezone/builds/moment-timezone-with-data.js',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment