Commit 1e74f942 by attiyaIshaque

Merge branch 'master' into ai/tnl3964-forum-vote-button

parents 269a8e9d 88aa4a90
@shard_1
Feature: Course export
I want to export my course to a tar.gz file to share with others or check into source control
# Disabling due to failure on master. 05/21/2014 TODO: fix
# Scenario: User is directed to problem with & in it when export fails
# Given I am in Studio editing a new unit
# When I add a "Blank Advanced Problem" "Advanced Problem" component
# And I edit and enter an ampersand
# And I export the course
# Then I get an error dialog
# And I can click to go to the unit with the error
# pylint: disable=missing-docstring
# pylint: disable=redefined-outer-name
# pylint: disable=unused-argument
from lettuce import world, step
from component_settings_editor_helpers import enter_xml_in_advanced_problem
from nose.tools import assert_true, assert_equal
from contentstore.utils import reverse_usage_url
@step('I go to the export page$')
def i_go_to_the_export_page(step):
world.click_tools()
link_css = 'li.nav-course-tools-export a'
world.css_click(link_css)
@step('I export the course$')
def i_export_the_course(step):
step.given('I go to the export page')
world.css_click('a.action-export')
@step('I edit and enter bad XML$')
def i_enter_bad_xml(step):
enter_xml_in_advanced_problem(
step,
"""<problem><h1>Smallest Canvas</h1>
<p>You want to make the smallest canvas you can.</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false"><verbatim><canvas id="myCanvas" width = 10 height = 100> </canvas></verbatim></choice>
<choice correct="true"><code><canvas id="myCanvas" width = 10 height = 10> </canvas></code></choice>
</choicegroup>
</multiplechoiceresponse>
</problem>"""
)
@step('I edit and enter an ampersand$')
def i_enter_an_ampersand(step):
enter_xml_in_advanced_problem(step, "<problem>&</problem>")
@step('I get an error dialog$')
def get_an_error_dialog(step):
assert_true(world.is_css_present("div.prompt.error"))
@step('I can click to go to the unit with the error$')
def i_click_on_error_dialog(step):
world.css_click("button.action-primary")
problem_string = unicode(world.scenario_dict['COURSE'].id.make_usage_key("problem", 'ignore'))
problem_string = u"Problem {}".format(problem_string[:problem_string.rfind('ignore')])
css_selector = "span.inline-error"
world.wait_for_visible(css_selector)
assert_true(
world.css_html(css_selector).startswith(problem_string),
u"{} does not start with {}".format(
world.css_html(css_selector), problem_string
))
# we don't know the actual ID of the vertical. So just check that we did go to a
# vertical page in the course (there should only be one).
vertical_usage_key = world.scenario_dict['COURSE'].id.make_usage_key("vertical", "test")
vertical_url = reverse_usage_url('container_handler', vertical_usage_key)
# Remove the trailing "/None" from the URL - we don't know the course ID, so we just want to
# check that we visited a vertical URL.
if vertical_url.endswith("/test") or vertical_url.endswith("@test"):
vertical_url = vertical_url[:-5]
assert_equal(1, world.browser.url.count(vertical_url))
......@@ -101,27 +101,3 @@ Feature: CMS.Problem Editor
Then I can see Reply to Annotation link
And I see that page has scrolled "down" when I click on "annotatable-reply" link
And I see that page has scrolled "up" when I click on "annotation-return" link
# Disabled 11/13/2013 after failing in master
# The screenshot showed that the LaTeX editor had the text "hi",
# but Selenium timed out waiting for the text to appear.
# It also caused later tests to fail with "UnexpectedAlertPresent"
#
# This feature will work in Firefox only when Firefox is the active window
# IE will not interact with the high level source in sauce labs
#@skip_internetexplorer
#Scenario: High Level source is persisted for LaTeX problem (bug STUD-280)
# Given I have created a LaTeX Problem
# When I edit and compile the High Level Source
# Then my change to the High Level Source is persisted
# And when I view the High Level Source I see my changes
# Disabled 10/28/13 due to flakiness observed in master
# Scenario: Exceptions don't cause problem to be uneditable (bug STUD-786)
#Given I have an empty course
#And I go to the import page
#And I import the file "get_html_exception_test.tar.gz"
#When I go to the unit "Probability and BMI"
#And I click on "edit a draft"
#Then I see a message that says "We're having trouble rendering your component"
#And I can edit the problem
@shard_2 @requires_stub_youtube
Feature: CMS Video Component
As a course author, I want to be able to view my created videos in Studio
# 2
# Disabled 2/19/14 after intermittent failures in master
#Scenario: Check that position is stored on page refresh, position within start-end range
# Given I have created a Video component with subtitles
# And Make sure captions are closed
# And I edit the component
# And I open tab "Advanced"
# And I set value "00:00:12" to the field "Video Start Time"
# And I set value "00:00:24" to the field "Video Stop Time"
# And I save changes
# And I click video button "play"
# Then I see a range on slider
# Then I seek video to "16" seconds
# And I click video button "pause"
# And I reload the page
# And I click video button "play"
# Then I see video starts playing from "0:16" position
# 3
# Disabled 2/18/14 after intermittent failures in master
# Scenario: Check that position is stored on page refresh, position before start-end range
# Given I have created a Video component with subtitles
# And Make sure captions are closed
# And I edit the component
# And I open tab "Advanced"
# And I set value "00:00:12" to the field "Video Start Time"
# And I set value "00:00:24" to the field "Video Stop Time"
# And I save changes
# And I click video button "play"
# Then I see a range on slider
# Then I seek video to "5" seconds
# And I click video button "pause"
# And I reload the page
# And I click video button "play"
# Then I see video starts playing from "0:12" position
# 4
# Disabled 2/18/14 after intermittent failures in master
# Scenario: Check that position is stored on page refresh, position after start-end range
# Given I have created a Video component with subtitles
# And Make sure captions are closed
# And I edit the component
# And I open tab "Advanced"
# And I set value "00:00:12" to the field "Video Start Time"
# And I set value "00:00:24" to the field "Video Stop Time"
# And I save changes
# And I click video button "play"
# Then I see a range on slider
# Then I seek video to "30" seconds
# And I click video button "pause"
# And I reload the page
# And I click video button "play"
# Then I see video starts playing from "0:12" position
# -*- coding: utf-8 -*-
# disable missing docstring
# pylint: disable=missing-docstring
from lettuce import world, step
from nose.tools import assert_true
from video_editor import RequestHandlerWithSessionId, success_upload_file
@step('I (?:upload|replace) handout file(?: by)? "([^"]*)"$')
def upload_handout(step, filename):
world.css_click('.wrapper-comp-setting.file-uploader .upload-action')
success_upload_file(filename)
@step('I can download handout file( in editor)? with mime type "([^"]*)"$')
def i_can_download_handout_with_mime_type(_step, is_editor, mime_type):
if is_editor:
selector = '.wrapper-comp-setting.file-uploader .download-action'
else:
selector = '.video-handout.video-download-button a'
button = world.css_find(selector).first
url = button['href']
request = RequestHandlerWithSessionId()
assert_true(request.get(url).is_success())
assert_true(request.check_header('content-type', mime_type))
@step('I clear handout$')
def clear_handout(_step):
world.css_click('.wrapper-comp-setting.file-uploader .setting-clear')
@step('I have created a Video component with handout file "([^"]*)"')
def create_video_with_handout(_step, filename):
_step.given('I have created a Video component')
_step.given('I edit the component')
_step.given('I open tab "Advanced"')
_step.given('I upload handout file "{0}"'.format(filename))
......@@ -283,6 +283,17 @@ else:
DATABASES = AUTH_TOKENS['DATABASES']
MODULESTORE = convert_module_store_setting_if_needed(AUTH_TOKENS.get('MODULESTORE', MODULESTORE))
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ENV_TOKENS.get(
'MODULESTORE_FIELD_OVERRIDE_PROVIDERS',
MODULESTORE_FIELD_OVERRIDE_PROVIDERS
)
XBLOCK_FIELD_DATA_WRAPPERS = ENV_TOKENS.get(
'XBLOCK_FIELD_DATA_WRAPPERS',
XBLOCK_FIELD_DATA_WRAPPERS
)
CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE']
DOC_STORE_CONFIG = AUTH_TOKENS['DOC_STORE_CONFIG']
# Datadog for events!
......
......@@ -383,6 +383,9 @@ XBLOCK_MIXINS = (
XBLOCK_SELECT_FUNCTION = prefer_xmodules
# Paths to wrapper methods which should be applied to every XBlock's FieldData.
XBLOCK_FIELD_DATA_WRAPPERS = ()
############################ Modulestore Configuration ################################
MODULESTORE_BRANCH = 'draft-preferred'
......@@ -417,6 +420,10 @@ MODULESTORE = {
}
}
# Modulestore-level field override providers. These field override providers don't
# require student context.
MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ()
#################### Python sandbox ############################################
CODE_JAIL = {
......
../../common/static/edx-pattern-library
\ No newline at end of file
../../common/static/edx-ui-toolkit
\ No newline at end of file
......@@ -129,9 +129,9 @@ function ($, _, Backbone, gettext,
if (event && event.preventDefault) { event.preventDefault(); }
var model = this.model;
var self = this;
var titleText = gettext('Delete "<%= signatoryName %>" from the list of signatories?');
var titleTextTemplate = _.template(gettext('Delete "<%= signatoryName %>" from the list of signatories?'));
var confirm = new PromptView.Warning({
title: _.template(titleText, {signatoryName: model.get('name')}),
title: titleTextTemplate({signatoryName: model.get('name')}),
message: gettext('This action cannot be undone.'),
actions: {
primary: {
......
......@@ -66,9 +66,10 @@ var CourseGrader = Backbone.Model.extend({
else attrs.drop_count = intDropCount;
}
if (_.has(attrs, 'min_count') && _.has(attrs, 'drop_count') && !_.has(errors, 'min_count') && !_.has(errors, 'drop_count') && attrs.drop_count > attrs.min_count) {
errors.drop_count = _.template(
gettext("Cannot drop more <% attrs.types %> than will assigned."),
attrs, {variable: 'attrs'});
var template = _.template(
gettext("Cannot drop more <%= types %> assignments than are assigned.")
);
errors.drop_count = template({types: attrs.type});
}
if (!_.isEmpty(errors)) return errors;
}
......
......@@ -15,8 +15,7 @@ var FileUpload = Backbone.Model.extend({
validate: function(attrs, options) {
if(attrs.selectedFile && !this.checkTypeValidity(attrs.selectedFile)) {
return {
message: _.template(
gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."),
message: _.template(gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."))( // jshint ignore:line
this.formatValidTypes()
),
attributes: {selectedFile: true}
......@@ -64,7 +63,7 @@ var FileUpload = Backbone.Model.extend({
}
var or = gettext('or');
var formatTypes = function(types) {
return _.template('<%= initial %> <%= or %> <%= last %>', {
return _.template('<%= initial %> <%= or %> <%= last %>')({
initial: _.initial(types).join(', '),
or: or,
last: _.last(types)
......
......@@ -359,12 +359,12 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "draggabilly",
makeDraggable: function (element, options) {
var draggable;
options = _.defaults({
type: null,
handleClass: null,
droppableClass: null,
parentLocationSelector: null,
refresh: null,
ensureChildrenRendered: null
type: undefined,
handleClass: undefined,
droppableClass: undefined,
parentLocationSelector: undefined,
refresh: undefined,
ensureChildrenRendered: undefined
}, options);
if ($(element).data('droppable-class') !== options.droppableClass) {
......
......@@ -67,7 +67,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset
ViewUtils.hideLoadingIndicator();
// Create the table
this.$el.html(_.template(asset_library_template, {typeData: this.typeData}));
this.$el.html(_.template(asset_library_template)({typeData: this.typeData}));
tableBody = this.$('#asset-table-body');
this.tableBody = tableBody;
this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')});
......
......@@ -75,18 +75,18 @@ define([
},
getOutlineAnchorMessage: function () {
var message = gettext(
var message = _.escape(gettext(
/*
Translators: 'outlineAnchor' is an anchor pointing to
the course outline page.
*/
'This content group is not in use. Add a content group to any unit from the %(outlineAnchor)s.'
),
)),
anchor = str.sprintf(
'<a href="%(url)s" title="%(text)s">%(text)s</a>',
{
url: this.model.collection.parents[0].outlineUrl,
text: gettext('Course Outline')
text: _.escape(gettext('Course Outline'))
}
);
......
/*global course */
define(["js/views/baseview", "underscore", "underscore.string", "jquery", "gettext", "js/models/uploads", "js/views/uploads"],
function(BaseView, _, str, $, gettext, FileUploadModel, UploadDialogView) {
_.str = str; // used in template
......@@ -52,10 +54,8 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery", "gette
asset_path: this.$("input.chapter-asset-path").val()
});
var msg = new FileUploadModel({
title: _.template(
gettext("Upload a new PDF to “<%= name %>”"),
{name: window.course.escape('name')}
),
title: _.template(gettext("Upload a new PDF to “<%= name %>”"))(
{name: course.escape('name')}),
message: gettext("Please select a PDF file to upload."),
mimeTypes: ['application/pdf']
});
......
......@@ -54,8 +54,8 @@ define(['jquery', 'underscore', 'gettext', "js/views/baseview",
title: messages.alreadyMember.title,
message: _.template(
messages.alreadyMember.messageTpl,
{email: email, container: containerName},
{interpolate: /\{(.+?)}/g}
{interpolate: /\{(.+?)}/g})(
{email: email, container: containerName}
),
actions: {
primary: {
......@@ -140,7 +140,9 @@ define(['jquery', 'underscore', 'gettext', "js/views/baseview",
roles = _.object(_.pluck(this.roles, 'key'), _.pluck(this.roles, "name")),
adminRoleCount = this.getAdminRoleCount(),
viewHelpers = {
format: function (template, data) { return _.template(template, data, {interpolate: /\{(.+?)}/g}); }
format: function (template, data) {
return _.template(template, {interpolate: /\{(.+?)}/g})(data);
}
};
for (var i = 0; i < this.users.length; i++) {
var user = this.users[i],
......@@ -284,8 +286,8 @@ define(['jquery', 'underscore', 'gettext', "js/views/baseview",
title: self.messages.deleteUser.title,
message: _.template(
self.messages.deleteUser.messageTpl,
{email: email, container: self.containerName},
{interpolate: /\{(.+?)}/g}
{interpolate: /\{(.+?)}/g})(
{email: email, container: self.containerName}
),
actions: {
primary: {
......
......@@ -22,9 +22,7 @@ define(["underscore", "backbone", "gettext", "text!templates/paging-header.under
currentPage = collection.currentPage,
lastPage = collection.totalPages - 1,
messageHtml = this.messageHtml();
this.$el.html(_.template(paging_header_template, {
messageHtml: messageHtml
}));
this.$el.html(_.template(paging_header_template)({ messageHtml: messageHtml}));
this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0);
this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage);
return this;
......
......@@ -27,10 +27,9 @@ define(["js/views/baseview", "underscore", "gettext", "common/js/components/view
},
confirmDelete: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
var textbook = this.model, collection = this.model.collection;
var msg = new PromptView.Warning({
title: _.template(
gettext("Delete “<%= name %>”?"),
var textbook = this.model;
new PromptView.Warning({
title: _.template(gettext("Delete “<%= name %>”?"))(
{name: textbook.get('name')}
),
message: gettext("Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed."),
......
......@@ -18,7 +18,9 @@ function($, Backbone, _, Utils) {
uploadTpl: '#file-upload',
initialize: function () {
_.bindAll(this);
_.bindAll(this,
'changeHandler', 'clickHandler', 'xhrResetProgressBar', 'xhrProgressHandler', 'xhrCompleteHandler'
);
this.file = false;
this.render();
......
......@@ -29,7 +29,9 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
},
initialize: function () {
_.bindAll(this);
_.bindAll(this,
'importHandler', 'replaceHandler', 'chooseHandler', 'useExistingHandler', 'showError', 'hideError'
);
this.component_locator = this.$el.closest('[data-locator]').data('locator');
......
......@@ -78,6 +78,8 @@ src_paths:
- js/certificates
- js/factories
- common/js
- edx-pattern-library/js
- edx-ui-toolkit/js
# Paths to spec (test) JavaScript files
# We should define the custom path mapping in /coffee/spec/main.coffee as well e.g. certificates etc.
......
......@@ -73,6 +73,8 @@ src_paths:
- js/utils
- js/views
- common/js
- edx-pattern-library/js
- edx-ui-toolkit/js
# Paths to spec (test) JavaScript files
spec_paths:
......
......@@ -3,7 +3,7 @@
<h3 class="title">
<a href="#" class="toggle group-toggle <% if (showContentGroupUsages){ print('hide'); } else { print('show'); } %>-groups">
<i class="ui-toggle-expansion icon fa fa-caret-<% if (showContentGroupUsages){ print('down'); } else { print('right'); } %>"></i>
<%= name %>
<%- name %>
</a>
</h3>
</header>
......@@ -11,28 +11,28 @@
<ol class="collection-info group-configuration-info group-configuration-info-<% if(showContentGroupUsages){ print('block'); } else { print('inline'); } %>">
<% if (!_.isUndefined(id)) { %>
<li class="group-configuration-id"
><span class="group-configuration-label"><%= gettext('ID') %>: </span
><span class="group-configuration-value"><%= id %></span
><span class="group-configuration-label"><%- gettext('ID') %>: </span
><span class="group-configuration-value"><%- id %></span
></li>
<% } %>
<% if (!showContentGroupUsages) { %>
<li class="group-configuration-usage-count">
<%= usageCountMessage %>
<%- usageCountMessage %>
</li>
<% } %>
</ol>
<ul class="actions group-configuration-actions">
<li class="action action-edit">
<button class="edit"><i class="icon fa fa-pencil"></i> <%= gettext("Edit") %></button>
<button class="edit"><i class="icon fa fa-pencil"></i> <%- gettext("Edit") %></button>
</li>
<% if (_.isEmpty(usage)) { %>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Delete') %>">
<button class="delete action-icon"><i class="icon fa fa-trash-o"></i><span><%= gettext("Delete") %></span></button>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%- gettext('Delete') %>">
<button class="delete action-icon"><i class="icon fa fa-trash-o"></i><span><%- gettext("Delete") %></span></button>
</li>
<% } else { %>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Cannot delete when in use by a unit') %>">
<button class="delete action-icon is-disabled" aria-disabled="true" disabled="disabled"><i class="icon fa fa-trash-o"></i><span><%= gettext("Delete") %></span></button>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%- gettext('Cannot delete when in use by a unit') %>">
<button class="delete action-icon is-disabled" aria-disabled="true" disabled="disabled"><i class="icon fa fa-trash-o"></i><span><%- gettext("Delete") %></span></button>
</li>
<% } %>
</ul>
......@@ -41,17 +41,18 @@
<% if (showContentGroupUsages) { %>
<div class="collection-references wrapper-group-configuration-usages">
<% if (!_.isEmpty(usage)) { %>
<h4 class="intro group-configuration-usage-text"><%= gettext('This content group is used in:') %></h4>
<h4 class="intro group-configuration-usage-text"><%- gettext('This content group is used in:') %></h4>
<ol class="usage group-configuration-usage">
<% _.each(usage, function(unit) { %>
<li class="usage-unit group-configuration-usage-unit">
<p><a href=<%= unit.url %> ><%= unit.label %></a></p>
<p><a href=<%- unit.url %> ><%- unit.label %></a></p>
</li>
<% }) %>
</ol>
<% } else { %>
<p class="group-configuration-usage-text">
<%= outlineAnchorMessage %>
<!-- This contains an anchor link and therefore can't be escaped. -->
<%= outlineAnchorMessage %>
</p>
<% } %>
</div>
......
......@@ -3,7 +3,7 @@
<h3 class="title group-configuration-title">
<a href="#" class="toggle group-toggle <% if(showGroups){ print('hide'); } else { print('show'); } %>-groups">
<i class="ui-toggle-expansion icon fa fa-caret-<% if(showGroups){ print('down'); } else { print('right'); } %>"></i>
<%= name %>
<%- name %>
</a>
</h3>
</header>
......@@ -11,20 +11,20 @@
<ol class="collection-info group-configuration-info group-configuration-info-<% if(showGroups){ print('block'); } else { print('inline'); } %>">
<% if (!_.isUndefined(id)) { %>
<li class="group-configuration-id"
><span class="group-configuration-label"><%= gettext('ID') %>: </span
><span class="group-configuration-value"><%= id %></span
><span class="group-configuration-label"><%- gettext('ID') %>: </span
><span class="group-configuration-value"><%- id %></span
></li>
<% } %>
<% if (showGroups) { %>
<li class="collection-description group-configuration-description">
<%= description %>
<%- description %>
</li>
<% } else { %>
<li class="group-configuration-groups-count">
<%= groupsCountMessage %>
<%- groupsCountMessage %>
</li>
<li class="group-configuration-usage-count">
<%= usageCountMessage %>
<%- usageCountMessage %>
</li>
<% } %>
</ol>
......@@ -34,23 +34,23 @@
<ol class="collection-items groups groups-<%= index %>">
<% groups.each(function(group, groupIndex) { %>
<li class="item group group-<%= groupIndex %>">
<span class="name group-name"><%= group.get('name') %></span>
<span class="meta group-allocation"><%= allocation %>%</span>
<span class="name group-name"><%- group.get('name') %></span>
<span class="meta group-allocation"><%- allocation %>%</span>
</li>
<% }) %>
</ol>
<% } %>
<ul class="actions group-configuration-actions">
<li class="action action-edit">
<button class="edit"><i class="icon fa fa-pencil"></i> <%= gettext("Edit") %></button>
<button class="edit"><i class="icon fa fa-pencil"></i> <%- gettext("Edit") %></button>
</li>
<% if (_.isEmpty(usage)) { %>
<li class="action action-delete wrapper-delete-button">
<button class="delete action-icon"><i class="icon fa fa-trash-o"></i><span><%= gettext("Delete") %></span></button>
<button class="delete action-icon"><i class="icon fa fa-trash-o"></i><span><%- gettext("Delete") %></span></button>
</li>
<% } else { %>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%= gettext('Cannot delete when in use by an experiment') %>">
<button class="delete action-icon is-disabled" aria-disabled="true"><i class="icon fa fa-trash-o"></i><span><%= gettext("Delete") %></span></button>
<li class="action action-delete wrapper-delete-button" data-tooltip="<%- gettext('Cannot delete when in use by an experiment') %>">
<button class="delete action-icon is-disabled" aria-disabled="true"><i class="icon fa fa-trash-o"></i><span><%- gettext("Delete") %></span></button>
</li>
<% } %>
</ul>
......@@ -58,11 +58,11 @@
<% if(showGroups) { %>
<div class="collection-references wrapper-group-configuration-usages">
<% if (!_.isEmpty(usage)) { %>
<h4 class="intro group-configuration-usage-text"><%= gettext('This Group Configuration is used in:') %></h4>
<h4 class="intro group-configuration-usage-text"><%- gettext('This Group Configuration is used in:') %></h4>
<ol class="usage group-configuration-usage">
<% _.each(usage, function(unit) { %>
<li class="usage-unit group-configuration-usage-unit">
<p><a href=<%= unit.url %> ><%= unit.label %></a></p>
<p><a href=<%- unit.url %> ><%- unit.label %></a></p>
<% if (unit.validation) { %>
<p>
<% if (unit.validation.type === 'warning') { %>
......@@ -71,7 +71,7 @@
<i class="icon fa fa-exclamation-circle"></i>
<% } %>
<span class="usage-validation-message group-configuration-validation-message">
<%= unit.validation.text %>
<%- unit.validation.text %>
</span>
</p>
<% } %>
......@@ -80,6 +80,7 @@
</ol>
<% } else { %>
<p class="group-configuration-usage-text">
<!-- This contains an anchor link and therefore can't be escaped. -->
<%= outlineAnchorMessage %>
</p>
<% } %>
......
......@@ -2,17 +2,17 @@
<div class "error-header">
<p>
<%= _.template(
ngettext(
"There was {strong_start}{num_errors} validation error{strong_end} while trying to save the course settings in the database.",
"There were {strong_start}{num_errors} validation errors{strong_end} while trying to save the course settings in the database.",
num_errors
),
{
strong_start:'<strong>',
num_errors: num_errors,
strong_end: '</strong>'
},
{interpolate: /\{(.+?)\}/g})%>
ngettext(
"There was {strong_start}{num_errors} validation error{strong_end} while trying to save the course settings in the database.",
"There were {strong_start}{num_errors} validation errors{strong_end} while trying to save the course settings in the database.",
num_errors
),
{interpolate: /\{(.+?)\}/g})(
{
strong_start:'<strong>',
num_errors: num_errors,
strong_end: '</strong>'
})%>
<%= gettext("Please check the following validation feedbacks and reflect them in your course settings:")%></p>
</div>
......
......@@ -89,7 +89,7 @@ class ChooseModeView(View):
has_enrolled_professional = (CourseMode.is_professional_slug(enrollment_mode) and is_active)
if CourseMode.has_professional_mode(modes) and not has_enrolled_professional:
redirect_url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_key)})
if ecommerce_service.is_enabled(request):
if ecommerce_service.is_enabled(request.user):
professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL)
if professional_mode.sku:
redirect_url = ecommerce_service.checkout_page_url(professional_mode.sku)
......@@ -158,7 +158,7 @@ class ChooseModeView(View):
context["verified_description"] = verified_mode.description
if verified_mode.sku:
context["use_ecommerce_payment_flow"] = ecommerce_service.is_enabled(request)
context["use_ecommerce_payment_flow"] = ecommerce_service.is_enabled(request.user)
context["ecommerce_payment_page"] = ecommerce_service.payment_page_url()
context["sku"] = verified_mode.sku
......
......@@ -743,7 +743,7 @@ def dashboard(request):
}
ecommerce_service = EcommerceService()
if ecommerce_service.is_enabled(request):
if ecommerce_service.is_enabled(request.user):
context.update({
'use_ecommerce_payment_flow': True,
'ecommerce_payment_page': ecommerce_service.payment_page_url(),
......
......@@ -361,9 +361,14 @@ function (VideoPlayer) {
describe('onSeek', function () {
beforeEach(function () {
// jasmine.Clock can't be used to fake out debounce with newer versions of underscore
spyOn(_, 'debounce').andCallFake(function (func) {
return function () {
func.apply(this, arguments);
};
});
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
jasmine.Clock.useMock();
spyOn(state.videoPlayer, 'duration').andReturn(120);
});
......@@ -384,9 +389,6 @@ function (VideoPlayer) {
spyOn(state.videoPlayer, 'stopTimer');
spyOn(state.videoPlayer, 'runTimer');
state.videoPlayer.seekTo(10);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
expect(state.videoPlayer.currentTime).toBe(10);
expect(state.videoPlayer.stopTimer).toHaveBeenCalled();
expect(state.videoPlayer.runTimer).toHaveBeenCalled();
......@@ -399,9 +401,6 @@ function (VideoPlayer) {
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 30 }
);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
expect(state.videoPlayer.currentTime).toBe(30);
expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(30, true);
});
......@@ -413,9 +412,6 @@ function (VideoPlayer) {
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 30 }
);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
expect(state.videoPlayer.currentTime).toBe(30);
expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(30, true);
});
......@@ -426,17 +422,11 @@ function (VideoPlayer) {
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 20 }
);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
state.videoPlayer.pause();
expect(state.videoPlayer.currentTime).toBe(20);
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 10 }
);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
expect(state.videoPlayer.currentTime).toBe(10);
});
......
......@@ -13,7 +13,7 @@ var setupFullScreenModal = function() {
"largeALT": smallImageObject.attr('alt'),
"largeSRC": largeImageSRC
};
var html = _.template($("#image-modal-tpl").text(), data);
var html = _.template($("#image-modal-tpl").text())(data);
$(this).replaceWith(html);
}
});
......
......@@ -1160,7 +1160,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
contentstore=None,
doc_store_config=None, # ignore if passed up
metadata_inheritance_cache_subsystem=None, request_cache=None,
xblock_mixins=(), xblock_select=None, disabled_xblock_types=(), # pylint: disable=bad-continuation
xblock_mixins=(), xblock_select=None, xblock_field_data_wrappers=(), disabled_xblock_types=(), # pylint: disable=bad-continuation
# temporary parms to enable backward compatibility. remove once all envs migrated
db=None, collection=None, host=None, port=None, tz_aware=True, user=None, password=None,
# allow lower level init args to pass harmlessly
......@@ -1177,6 +1177,7 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
self.request_cache = request_cache
self.xblock_mixins = xblock_mixins
self.xblock_select = xblock_select
self.xblock_field_data_wrappers = xblock_field_data_wrappers
self.disabled_xblock_types = disabled_xblock_types
self.contentstore = contentstore
......
......@@ -120,11 +120,25 @@ def load_function(path):
"""
Load a function by name.
path is a string of the form "path.to.module.function"
returns the imported python object `function` from `path.to.module`
Arguments:
path: String of the form 'path.to.module.function'. Strings of the form
'path.to.module:Class.function' are also valid.
Returns:
The imported object 'function'.
"""
module_path, _, name = path.rpartition('.')
return getattr(import_module(module_path), name)
if ':' in path:
module_path, _, method_path = path.rpartition(':')
module = import_module(module_path)
class_name, method_name = method_path.split('.')
_class = getattr(module, class_name)
function = getattr(_class, method_name)
else:
module_path, _, name = path.rpartition('.')
function = getattr(import_module(module_path), name)
return function
def create_modulestore_instance(
......@@ -179,12 +193,15 @@ def create_modulestore_instance(
else:
disabled_xblock_types = ()
xblock_field_data_wrappers = [load_function(path) for path in settings.XBLOCK_FIELD_DATA_WRAPPERS]
return class_(
contentstore=content_store,
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
request_cache=request_cache,
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
xblock_select=getattr(settings, 'XBLOCK_SELECT_FUNCTION', None),
xblock_field_data_wrappers=xblock_field_data_wrappers,
disabled_xblock_types=disabled_xblock_types,
doc_store_config=doc_store_config,
i18n_service=i18n_service or ModuleI18nService(),
......
......@@ -218,6 +218,17 @@ class InheritanceMixin(XBlockMixin):
default=False
)
self_paced = Boolean(
display_name=_('Self Paced'),
help=_(
'Set this to "true" to mark this course as self-paced. Self-paced courses do not have '
'due dates for assignments, and students can progress through the course at any rate before '
'the course ends.'
),
default=False,
scope=Scope.settings
)
def compute_inherited_metadata(descriptor):
"""Given a descriptor, traverse all of its descendants and do metadata
......
......@@ -12,27 +12,24 @@ structure:
}
"""
import pymongo
import sys
import logging
import copy
from datetime import datetime
from importlib import import_module
import logging
import pymongo
import re
import sys
from uuid import uuid4
from bson.son import SON
from datetime import datetime
from contracts import contract, new_contract
from fs.osfs import OSFS
from mongodb_proxy import autoretry_read
from path import Path as path
from pytz import UTC
from contracts import contract, new_contract
from importlib import import_module
from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey
from opaque_keys.edx.locations import Location, BlockUsageLocator
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locations import Location, BlockUsageLocator, SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
from path import Path as path
from pytz import UTC
from xblock.core import XBlock
from xblock.exceptions import InvalidScopeError
from xblock.fields import Scope, ScopeIds, Reference, ReferenceList, ReferenceValueDict
......@@ -54,6 +51,7 @@ from xmodule.modulestore.xml import CourseLocationManager
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
from xmodule.services import SettingsService
log = logging.getLogger(__name__)
new_contract('CourseKey', CourseKey)
......@@ -318,6 +316,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
).replace(tzinfo=UTC)
module._edit_info['published_by'] = raw_metadata.get('published_by')
for wrapper in self.modulestore.xblock_field_data_wrappers:
module._field_data = wrapper(module, module._field_data) # pylint: disable=protected-access
# decache any computed pending field settings
module.save()
return module
......
import sys
import logging
from contracts import contract, new_contract
from fs.osfs import OSFS
from lazy import lazy
......@@ -7,6 +8,7 @@ from xblock.runtime import KvsFieldData, KeyValueStore
from xblock.fields import ScopeIds
from xblock.core import XBlock
from opaque_keys.edx.locator import BlockUsageLocator, LocalId, CourseLocator, LibraryLocator, DefinitionLocator
from xmodule.library_tools import LibraryToolsService
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.error_module import ErrorDescriptor
......@@ -263,6 +265,10 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
module.update_version = edit_info.update_version
module.source_version = edit_info.source_version
module.definition_locator = DefinitionLocator(block_key.type, definition_id)
for wrapper in self.modulestore.xblock_field_data_wrappers:
module._field_data = wrapper(module, module._field_data) # pylint: disable=protected-access
# decache any pending field settings
module.save()
......
......@@ -589,6 +589,20 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
return xml
def create_youtube_url(self, youtube_id):
"""
Args:
youtube_id: The ID of the video to create a link for
Returns:
A full youtube url to the video whose ID is passed in
"""
if youtube_id:
return 'https://www.youtube.com/watch?v={0}'.format(youtube_id)
else:
return ''
def get_context(self):
"""
Extend context by data for transcript basic tab.
......@@ -612,10 +626,7 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if val_youtube_id:
video_id = val_youtube_id
if video_id:
return 'http://youtu.be/{0}'.format(video_id)
else:
return ''
return self.create_youtube_url(video_id)
_ = self.runtime.service(self, "i18n").ugettext
video_url.update({
......@@ -848,7 +859,8 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
val_video_data = edxval_api.get_video_info(self.edx_video_id)
# Unfortunately, the VAL API is inconsistent in how it returns the encodings, so remap here.
for enc_vid in val_video_data.pop('encoded_videos'):
encoded_videos[enc_vid['profile']] = {key: enc_vid[key] for key in ["url", "file_size"]}
if enc_vid['profile'] in video_profile_names:
encoded_videos[enc_vid['profile']] = {key: enc_vid[key] for key in ["url", "file_size"]}
except edxval_api.ValVideoNotFoundError:
pass
......@@ -861,6 +873,14 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
"file_size": 0, # File size is unknown for fallback URLs
}
# Include youtube link if there is no encoding for mobile- ie only a fallback URL or no encodings at all
# We are including a fallback URL for older versions of the mobile app that don't handle Youtube urls
if self.youtube_id_1_0:
encoded_videos["youtube"] = {
"url": self.create_youtube_url(self.youtube_id_1_0),
"file_size": 0, # File size is not relevant for external link
}
transcripts_info = self.get_transcripts_info()
transcripts = {
lang: self.runtime.handler_url(self, 'transcript', 'download', query="lang=" + lang, thirdparty=True)
......
......@@ -118,7 +118,7 @@ describe "ThreadResponseShowView", ->
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
it "allows a moderator to mark an answer in a question thread", ->
DiscussionUtil.loadRoles({"Moderator": parseInt(window.user.id)})
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
......
class @DiscussionFilter
# TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
# for use with a very similar category dropdown in the New Post form. The two menus' implementations
# should be merged into a single reusable view.
@filterDrop: (e) ->
$drop = $(e.target).parents('.topic-menu-wrapper')
query = $(e.target).val()
$items = $drop.find('.topic-menu-item')
if(query.length == 0)
$items.removeClass('hidden')
return;
$items.addClass('hidden')
$items.each (i) ->
path = $(this).parents(".topic-menu-item").andSelf()
pathTitles = path.children(".topic-title").map((i, elem) -> $(elem).text()).get()
pathText = pathTitles.join(" / ").toLowerCase()
if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1)
$(this).removeClass('hidden')
# show children
$(this).find('.topic-menu-item').removeClass('hidden');
# show parents
$(this).parents('.topic-menu-item').removeClass('hidden');
......@@ -19,7 +19,7 @@
this.threadType = this.model.get('thread_type');
this.topicId = this.model.get('commentable_id');
this.context = options.context || 'course';
_.bindAll(this);
_.bindAll(this, 'updateHandler', 'cancelHandler');
return this;
},
......
......@@ -39,9 +39,9 @@ if Backbone?
@searchAlertCollection.on "add", (searchAlert) =>
content = _.template(
$("#search-alert-template").html(),
$("#search-alert-template").html())(
{'message': searchAlert.attributes.message, 'cid': searchAlert.cid}
)
)
@$(".search-alerts").append(content)
@$("#search-alert-" + searchAlert.cid + " a.dismiss").bind "click", searchAlert, (event) =>
@removeSearchAlert(event.data.cid)
......@@ -491,7 +491,7 @@ if Backbone?
message = interpolate(
_.escape(gettext('Show posts by %(username)s.')),
{"username":
_.template('<a class="link-jump" href="<%= url %>"><%- username %></a>', {
_.template('<a class="link-jump" href="<%= url %>"><%- username %></a>')({
url: DiscussionUtil.urlFor("user_profile", response.users[0].id),
username: response.users[0].username
})
......
......@@ -6,7 +6,7 @@
'click .post-topic-button': 'toggleTopicDropdown',
'click .topic-menu-wrapper': 'handleTopicEvent',
'click .topic-filter-label': 'ignoreClick',
'keyup .topic-filter-input': this.DiscussionFilter.filterDrop
'keyup .topic-filter-input': 'filterDrop'
},
attributes: {
......@@ -17,7 +17,9 @@
this.course_settings = options.course_settings;
this.currentTopicId = options.topicId;
this.maxNameWidth = 100;
_.bindAll(this);
_.bindAll(this,
'toggleTopicDropdown', 'handleTopicEvent', 'hideTopicDropdown', 'ignoreClick'
);
return this;
},
......@@ -34,7 +36,7 @@
render: function() {
var context = _.clone(this.course_settings.attributes);
context.topics_html = this.renderCategoryMap(this.course_settings.get('category_map'));
this.$el.html(_.template($('#topic-template').html(), context));
this.$el.html(_.template($('#topic-template').html())(context));
this.dropdownButton = this.$('.post-topic-button');
this.topicMenu = this.$('.topic-menu-wrapper');
this.selectedTopic = this.$('.js-selected-topic');
......@@ -187,6 +189,38 @@
}
}
return name;
},
// TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
// for use with a very similar category dropdown in the New Post form. The two menus' implementations
// should be merged into a single reusable view.
filterDrop: function (e) {
var $drop, $items, query;
$drop = $(e.target).parents('.topic-menu-wrapper');
query = $(e.target).val();
$items = $drop.find('.topic-menu-item');
if (query.length === 0) {
$items.removeClass('hidden');
return;
}
$items.addClass('hidden');
$items.each(function (_index, item) {
var path, pathText, pathTitles;
path = $(item).parents(".topic-menu-item").andSelf();
pathTitles = path.children(".topic-title").map(function (_, elem) {
return $(elem).text();
}).get();
pathText = pathTitles.join(" / ").toLowerCase();
if (query.split(" ").every(function (term) {
return pathText.search(term.toLowerCase()) !== -1;
})) {
$(item).removeClass('hidden');
$(item).find('.topic-menu-item').removeClass('hidden');
$(item).parents('.topic-menu-item').removeClass('hidden');
}
});
}
});
}
......
......@@ -17,7 +17,7 @@ if Backbone?
mode: @mode,
form_id: @mode + (if @topicId then "-" + @topicId else "")
})
@$el.html(_.template($("#new-post-template").html(), context))
@$el.html(_.template($("#new-post-template").html())(context))
threadTypeTemplate = _.template($("#thread-type-template").html());
if $('.js-group-select').is(':disabled')
$('.group-selector-wrapper').addClass('disabled')
......
......@@ -79,6 +79,9 @@
* underlying server API.
*/
getPage: function () {
// TODO: this.currentPage is currently returning a function sometimes when it is called.
// It is possible it always did this, but we either need to investigate more, or just wait until
// we replace this code with the pattern library.
return this.currentPage + (this.isZeroIndexed ? 1 : 0);
},
......
......@@ -244,7 +244,7 @@
if (!validateTotalKeyLength(key_field_selectors)) {
$(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding);
$(selectors.errorMessage).html(
'<p>' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + '</p>'
'<p>' + _.template(message_tpl)({limit: MAX_SUM_KEY_LENGTH}) + '</p>'
);
$(selectors.save).addClass(classes.disabled);
} else {
......
......@@ -2,7 +2,7 @@
'use strict';
define(["jquery", "underscore", "underscore.string", "common/js/components/views/feedback"],
function($, _, str, SystemFeedbackView) {
str = str || _.str;
var Alert = SystemFeedbackView.extend({
options: $.extend({}, SystemFeedbackView.prototype.options, {
type: "alert"
......
......@@ -2,7 +2,7 @@
'use strict';
define(["jquery", "underscore", "underscore.string", "common/js/components/views/feedback"],
function($, _, str, SystemFeedbackView) {
str = str || _.str;
var Notification = SystemFeedbackView.extend({
options: $.extend({}, SystemFeedbackView.prototype.options, {
type: "notification",
......
......@@ -2,7 +2,7 @@
'use strict';
define(["jquery", "underscore", "underscore.string", "common/js/components/views/feedback"],
function($, _, str, SystemFeedbackView) {
str = str || _.str;
var Prompt = SystemFeedbackView.extend({
options: $.extend({}, SystemFeedbackView.prototype.options, {
type: "prompt",
......
......@@ -50,7 +50,7 @@
},
render: function () {
this.$el.html(_.template(paginatedViewTemplate, {type: this.type}));
this.$el.html(_.template(paginatedViewTemplate)({type: this.type}));
this.assign(this.listView, '.' + this.type + '-list');
if (this.headerView) {
this.assign(this.headerView, '.' + this.type + '-paging-header');
......
......@@ -30,7 +30,7 @@
this.$el.removeClass('hidden');
}
}
this.$el.html(_.template(paging_footer_template, {
this.$el.html(_.template(paging_footer_template)({
current_page: this.collection.getPage(),
total_pages: this.collection.totalPages
}));
......
......@@ -33,7 +33,7 @@
context, true
);
}
this.$el.html(_.template(headerTemplate, {
this.$el.html(_.template(headerTemplate)({
message: message,
srInfo: this.srInfo,
sortableFields: this.collection.sortableFields,
......
......@@ -37,7 +37,7 @@
},
render: function() {
this.$el.html(_.template(searchFieldTemplate, {
this.$el.html(_.template(searchFieldTemplate)({
type: this.type,
searchString: this.collection.searchString,
searchLabel: this.label
......
......@@ -15,9 +15,6 @@
* by the access view, but doing it here helps keep the
* utility self-contained.
*/
if (_.isUndefined(_s)) {
_s = _.str;
}
_.mixin( _s.exports() );
utils = (function(){
......
<% if (!readOnly) { %>
<ul class="<%= contentType %>-actions-list">
<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
<% _.each(primaryActions, function(action) { print(_.template($('#forum-action-' + action).html())({})) }) %>
<li class="actions-item is-visible">
<div class="more-wrapper">
<a href="javascript:void(0)" class="action-button action-more" role="button" aria-haspopup="true" aria-controls="action-menu-<%= contentId %>">
......@@ -9,7 +9,7 @@
</a>
<div class="actions-dropdown" id="action-menu-<%= contentType %>" aria-expanded="false">
<ul class="actions-dropdown-list">
<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html(), {})) }) %>
<% _.each(secondaryActions, function(action) { print(_.template($('#forum-action-' + action).html())({})) }) %>
</ul>
</div>
</div>
......
......@@ -2,7 +2,7 @@
<div class="response-body"><%- body %></div>
<%=
_.template(
$('#forum-actions').html(),
$('#forum-actions').html())(
{
contentId: cid,
contentType: 'comment',
......
......@@ -44,7 +44,7 @@
<div class="response-header-actions">
<%=
_.template(
$('#forum-actions').html(),
$('#forum-actions').html())(
{
contentId: cid,
contentType: 'response',
......
......@@ -30,7 +30,7 @@
<div class="post-header-actions post-extended-content">
<%=
_.template(
$('#forum-actions').html(),
$('#forum-actions').html())(
{
contentId: cid,
contentType: 'post',
......
../../../node_modules/edx-pattern-library/pattern-library/fonts
\ No newline at end of file
../../../node_modules/edx-pattern-library/pattern-library/js
\ No newline at end of file
../../../node_modules/edx-ui-toolkit/src/js
\ No newline at end of file
......@@ -115,6 +115,27 @@ describe("Formula Equation Preview", function () {
]);
});
it('does not request again if the initial request has already been made', function () {
// jshint undef:false
expect(Problem.inputAjax.callCount).toEqual(1);
// Reset the spy in order to check calls again.
Problem.inputAjax.reset();
// Enabling the formulaEquationPreview again to see if this will
// reinitialize input request once again.
formulaEquationPreview.enable();
// This part may be asynchronous, so wait.
waitsFor(function () {
return !Problem.inputAjax.wasCalled;
}, "times out in case of AJAX call", 1000);
// Expect Problem.inputAjax was not called as input request was
// initialized before.
expect(Problem.inputAjax).not.toHaveBeenCalled();
});
it('makes a request on user input', function () {
Problem.inputAjax.reset();
$('#input_THE_ID').val('user_input').trigger('input');
......
......@@ -58,9 +58,17 @@ formulaEquationPreview.enable = function () {
throttledRequest(inputData, this.value);
};
$this.on("input", initializeRequest);
// Ask for initial preview.
initializeRequest.call(this);
if (!$this.data("inputInitialized")) {
// Hack alert: since this javascript file is loaded every time a
// problem with mathjax preview is loaded, we wrap this step in this
// condition to make sure we don't attach multiple event listeners
// per math input if multiple such problems are loaded on a page.
$this.on("input", initializeRequest);
// Ask for initial preview.
initializeRequest.call(this);
// indicates that the initial preview is done for current $this!
$this.data("inputInitialized", true);
}
}
/**
......
......@@ -21,7 +21,7 @@
*/
var interpolate_ntext = function (singular, plural, count, values) {
var text = count === 1 ? singular : plural;
return _.template(text, values, {interpolate: /\{(.+?)\}/g});
return _.template(text, {interpolate: /\{(.+?)\}/g})(values);
};
this.interpolate_ntext = interpolate_ntext;
......@@ -42,7 +42,7 @@
* @returns the text with placeholder values filled in
*/
var interpolate_text = function (text, values) {
return _.template(text, values, {interpolate: /\{(.+?)\}/g});
return _.template(text, {interpolate: /\{(.+?)\}/g})(values);
};
this.interpolate_text = interpolate_text;
}).call(this, _);
......@@ -12,7 +12,7 @@
}
this.hide();
_.bindAll(this);
_.bindAll(this, 'show', 'hide', 'showTooltip', 'moveTooltip', 'hideTooltip', 'click');
this.bindEvents();
};
......
......@@ -50,6 +50,8 @@ lib_paths:
# Paths to source JavaScript files
src_paths:
- common/js
- edx-pattern-library/js
- edx-ui-toolkit/js
# Paths to spec (test) JavaScript files
spec_paths:
......
../../../node_modules/edx-pattern-library/pattern-library/fonts
\ No newline at end of file
../../../node_modules/edx-pattern-library/pattern-library/js
\ No newline at end of file
......@@ -3,50 +3,50 @@
// Official animation shorthand property.
@mixin animation ($animations...) {
@include prefixer(animation, $animations, webkit moz spec);
@include prefixer(animation, $animations, spec);
}
// Individual Animation Properties
@mixin animation-name ($names...) {
@include prefixer(animation-name, $names, webkit moz spec);
@include prefixer(animation-name, $names, spec);
}
@mixin animation-duration ($times...) {
@include prefixer(animation-duration, $times, webkit moz spec);
@include prefixer(animation-duration, $times, spec);
}
@mixin animation-timing-function ($motions...) {
// ease | linear | ease-in | ease-out | ease-in-out
@include prefixer(animation-timing-function, $motions, webkit moz spec);
@include prefixer(animation-timing-function, $motions, spec);
}
@mixin animation-iteration-count ($values...) {
// infinite | <number>
@include prefixer(animation-iteration-count, $values, webkit moz spec);
@include prefixer(animation-iteration-count, $values, spec);
}
@mixin animation-direction ($directions...) {
// normal | alternate
@include prefixer(animation-direction, $directions, webkit moz spec);
@include prefixer(animation-direction, $directions, spec);
}
@mixin animation-play-state ($states...) {
// running | paused
@include prefixer(animation-play-state, $states, webkit moz spec);
@include prefixer(animation-play-state, $states, spec);
}
@mixin animation-delay ($times...) {
@include prefixer(animation-delay, $times, webkit moz spec);
@include prefixer(animation-delay, $times, spec);
}
@mixin animation-fill-mode ($modes...) {
// none | forwards | backwards | both
@include prefixer(animation-fill-mode, $modes, webkit moz spec);
@include prefixer(animation-fill-mode, $modes, spec);
}
......@@ -2,5 +2,5 @@
// Backface-visibility mixin
//************************************************************************//
@mixin backface-visibility($visibility) {
@include prefixer(backface-visibility, $visibility, webkit spec);
@include prefixer(backface-visibility, $visibility, spec);
}
......@@ -4,11 +4,9 @@
//************************************************************************//
@mixin background($backgrounds...) {
$webkit-backgrounds: ();
$spec-backgrounds: ();
@each $background in $backgrounds {
$webkit-background: ();
$spec-background: ();
$background-type: type-of($background);
......@@ -19,37 +17,30 @@
$gradient-type: str-slice($background-str, 0, 6);
@if $url-str == "url" {
$webkit-background: $background;
$spec-background: $background;
}
@else if $gradient-type == "linear" {
$gradients: _linear-gradient-parser("#{$background}");
$webkit-background: map-get($gradients, webkit-image);
$spec-background: map-get($gradients, spec-image);
$spec-background: map-get($gradients, spec-image);
}
@else if $gradient-type == "radial" {
$gradients: _radial-gradient-parser("#{$background}");
$webkit-background: map-get($gradients, webkit-image);
$spec-background: map-get($gradients, spec-image);
$spec-background: map-get($gradients, spec-image);
}
@else {
$webkit-background: $background;
$spec-background: $background;
}
}
@else {
$webkit-background: $background;
$spec-background: $background;
}
$webkit-backgrounds: append($webkit-backgrounds, $webkit-background, comma);
$spec-backgrounds: append($spec-backgrounds, $spec-background, comma);
$spec-backgrounds: append($spec-backgrounds, $spec-background, comma);
}
background: $webkit-backgrounds;
background: $spec-backgrounds;
}
@mixin box-sizing ($box) {
// content-box | border-box | inherit
@include prefixer(box-sizing, $box, webkit moz spec);
@include prefixer(box-sizing, $box, spec);
}
@mixin perspective($depth: none) {
// none | <length>
@include prefixer(perspective, $depth, webkit moz spec);
@include prefixer(perspective, $depth, spec);
}
@mixin perspective-origin($value: 50% 50%) {
@include prefixer(perspective-origin, $value, webkit moz spec);
@include prefixer(perspective-origin, $value, spec);
}
......@@ -34,6 +34,5 @@
$shape-size-spec: if(($shape-size-spec != ' ') and ($pos == null), '#{$shape-size-spec}, ', '#{$shape-size-spec} ');
background-color: $fallback-color;
background-image: -webkit-radial-gradient(unquote(#{$pos}#{$shape-size}#{$full}));
background-image: unquote("radial-gradient(#{$shape-size-spec}#{$pos-spec}#{$full})");
}
@mixin transform($property: none) {
// none | <transform-function>
@include prefixer(transform, $property, webkit moz ms o spec);
@include prefixer(transform, $property, spec);
}
@mixin transform-origin($axes: 50%) {
// x-axis - left | center | right | length | %
// y-axis - top | center | bottom | length | %
// z-axis - length
@include prefixer(transform-origin, $axes, webkit moz ms o spec);
@include prefixer(transform-origin, $axes, spec);
}
@mixin transform-style ($style: flat) {
@include prefixer(transform-style, $style, webkit moz ms o spec);
@include prefixer(transform-style, $style, spec);
}
......@@ -4,74 +4,29 @@
// @include transition-property (transform, opacity);
@mixin transition ($properties...) {
// Fix for vendor-prefix transform property
$needs-prefixes: false;
$webkit: ();
$moz: ();
$spec: ();
// Create lists for vendor-prefixed transform
@each $list in $properties {
@if nth($list, 1) == "transform" {
$needs-prefixes: true;
$list1: -webkit-transform;
$list2: -moz-transform;
$list3: ();
@each $var in $list {
$list3: join($list3, $var);
@if $var != "transform" {
$list1: join($list1, $var);
$list2: join($list2, $var);
}
}
$webkit: append($webkit, $list1);
$moz: append($moz, $list2);
$spec: append($spec, $list3);
}
// Create lists for non-prefixed transition properties
@else {
$webkit: append($webkit, $list, comma);
$moz: append($moz, $list, comma);
$spec: append($spec, $list, comma);
}
@if length($properties) >= 1 {
@include prefixer(transition, $properties, spec);
}
@if $needs-prefixes {
-webkit-transition: $webkit;
-moz-transition: $moz;
transition: $spec;
}
@else {
@if length($properties) >= 1 {
@include prefixer(transition, $properties, webkit moz spec);
}
@else {
$properties: all 0.15s ease-out 0s;
@include prefixer(transition, $properties, webkit moz spec);
}
$properties: all 0.15s ease-out 0s;
@include prefixer(transition, $properties, spec);
}
}
@mixin transition-property ($properties...) {
-webkit-transition-property: transition-property-names($properties, 'webkit');
-moz-transition-property: transition-property-names($properties, 'moz');
transition-property: transition-property-names($properties, false);
transition-property: transition-property-names($properties, false);
}
@mixin transition-duration ($times...) {
@include prefixer(transition-duration, $times, webkit moz spec);
@include prefixer(transition-duration, $times, spec);
}
@mixin transition-timing-function ($motions...) {
// ease | linear | ease-in | ease-out | ease-in-out | cubic-bezier()
@include prefixer(transition-timing-function, $motions, webkit moz spec);
@include prefixer(transition-timing-function, $motions, spec);
}
@mixin transition-delay ($times...) {
@include prefixer(transition-delay, $times, webkit moz spec);
@include prefixer(transition-delay, $times, spec);
}
@mixin user-select($arg: none) {
@include prefixer(user-select, $arg, webkit moz ms spec);
@include prefixer(user-select, $arg, spec);
}
......@@ -4,7 +4,7 @@ Auto-auth page (used to automatically log in during testing).
import re
import urllib
from bok_choy.page_object import PageObject, unguarded
from bok_choy.page_object import PageObject, unguarded, XSS_INJECTION
from . import AUTH_BASE_URL
......@@ -17,7 +17,7 @@ class AutoAuthPage(PageObject):
CONTENT_REGEX = r'.+? user (?P<username>\S+) \((?P<email>.+?)\) with password \S+ and user_id (?P<user_id>\d+)$'
def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None,
def __init__(self, browser, username=None, email=None, password=None, full_name=None, staff=None, course_id=None,
enrollment_mode=None, roles=None):
"""
Auto-auth is an end-point for HTTP GET requests.
......@@ -25,6 +25,7 @@ class AutoAuthPage(PageObject):
but you can also specify credentials using querystring parameters.
`username`, `email`, and `password` are the user's credentials (strings)
'full_name' is the profile full name value
`staff` is a boolean indicating whether the user is global staff.
`course_id` is the ID of the course to enroll the student in.
Currently, this has the form "org/number/run"
......@@ -42,6 +43,8 @@ class AutoAuthPage(PageObject):
if username is not None:
self._params['username'] = username
self._params['full_name'] = full_name if full_name is not None else XSS_INJECTION
if email is not None:
self._params['email'] = email
......
......@@ -91,6 +91,7 @@ class ProblemPage(PageObject):
Fill in the answer to a numerical problem.
"""
self.q(css='div.problem section.inputtype input').fill(text)
self.wait_for_element_invisibility('.loading', 'wait for loading icon to disappear')
self.wait_for_ajax()
def click_check(self):
......
......@@ -508,12 +508,6 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
self.q(css='{} .section-name .save-button'.format(parent_css)).first.click()
self.wait_for_ajax()
def click_release_date(self):
"""
Open release date edit modal of first section in course outline
"""
self.q(css='div.section-published-date a.edit-release-date').first.click()
def sections(self):
"""
Returns the sections of this course outline page.
......
......@@ -94,6 +94,7 @@ class UsersPageMixin(PageObject):
self.click_add_button()
self.set_new_user_email(email)
self.click_submit_new_user_form()
self.wait_for_page()
def delete_user_from_course(self, email):
""" Deletes user from course/library """
......
......@@ -53,7 +53,7 @@ DISPLAY_NAME = "Component Display Name"
DEFAULT_SETTINGS = [
# basic
[DISPLAY_NAME, 'Video', False],
['Default Video URL', 'http://youtu.be/3_yD_cEKoCk, , ', False],
['Default Video URL', 'https://www.youtube.com/watch?v=3_yD_cEKoCk, , ', False],
# advanced
[DISPLAY_NAME, 'Video', False],
......
......@@ -16,6 +16,7 @@ from path import Path as path
from bok_choy.javascript import js_defined
from bok_choy.web_app_test import WebAppTest
from bok_choy.promise import EmptyPromise, Promise
from bok_choy.page_object import XSS_INJECTION
from opaque_keys.edx.locator import CourseLocator
from pymongo import MongoClient, ASCENDING
from openedx.core.lib.tests.assertions.events import assert_event_matches, is_matching_event, EventMatchTolerates
......@@ -640,7 +641,7 @@ class UniqueCourseTest(WebAppTest):
'org': 'test_org',
'number': self.unique_id,
'run': 'test_run',
'display_name': 'Test Course' + self.unique_id
'display_name': 'Test Course' + XSS_INJECTION + self.unique_id
}
@property
......
......@@ -6,6 +6,7 @@ from unittest import skip
from nose.plugins.attrib import attr
from bok_choy.web_app_test import WebAppTest
from bok_choy.page_object import XSS_INJECTION
from ...pages.lms.account_settings import AccountSettingsPage
from ...pages.lms.auto_auth import AutoAuthPage
......@@ -33,12 +34,12 @@ class AccountSettingsTestMixin(EventsTestMixin, WebAppTest):
self.account_settings_page.visit()
self.account_settings_page.wait_for_ajax()
def log_in_as_unique_user(self, email=None):
def log_in_as_unique_user(self, email=None, full_name=None):
"""
Create a unique user and return the account's username and id.
"""
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
auto_auth_page = AutoAuthPage(self.browser, username=username, email=email).visit()
auto_auth_page = AutoAuthPage(self.browser, username=username, email=email, full_name=full_name).visit()
user_id = auto_auth_page.get_user_id()
return username, user_id
......@@ -122,7 +123,8 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
Initialize account and pages.
"""
super(AccountSettingsPageTest, self).setUp()
self.username, self.user_id = self.log_in_as_unique_user()
self.full_name = XSS_INJECTION
self.username, self.user_id = self.log_in_as_unique_user(full_name=self.full_name)
self.visit_account_settings_page()
def test_page_view_event(self):
......@@ -259,16 +261,16 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
self._test_text_field(
u'name',
u'Full Name',
self.username,
self.full_name,
u'@',
[u'another name', self.username],
[u'another name', self.full_name],
)
actual_events = self.wait_for_events(event_filter=self.settings_changed_event_filter, number_of_matches=2)
self.assert_events_match(
[
self.expected_settings_changed_event('name', self.username, 'another name'),
self.expected_settings_changed_event('name', 'another name', self.username),
self.expected_settings_changed_event('name', self.full_name, 'another name'),
self.expected_settings_changed_event('name', 'another name', self.full_name),
],
actual_events
)
......
......@@ -2,6 +2,7 @@
Base classes used by studio tests.
"""
from bok_choy.web_app_test import WebAppTest
from bok_choy.page_object import XSS_INJECTION
from ...pages.studio.auto_auth import AutoAuthPage
from ...fixtures.course import CourseFixture
from ...fixtures.library import LibraryFixture
......@@ -15,11 +16,12 @@ class StudioCourseTest(UniqueCourseTest):
Base class for all Studio course tests.
"""
def setUp(self, is_staff=False):
def setUp(self, is_staff=False, test_xss=True): # pylint: disable=arguments-differ
"""
Install a course with no content using a fixture.
"""
super(StudioCourseTest, self).setUp()
self.test_xss = test_xss
self.install_course_fixture(is_staff)
def install_course_fixture(self, is_staff=False):
......@@ -30,8 +32,21 @@ class StudioCourseTest(UniqueCourseTest):
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
self.course_info['display_name'],
)
if self.test_xss:
xss_injected_unique_id = XSS_INJECTION + self.unique_id
test_improper_escaping = {u"value": xss_injected_unique_id}
self.course_fixture.add_advanced_settings({
"advertised_start": test_improper_escaping,
"info_sidebar_name": test_improper_escaping,
"cert_name_short": test_improper_escaping,
"cert_name_long": test_improper_escaping,
"display_organization": test_improper_escaping,
"display_coursenumber": test_improper_escaping,
})
self.course_info['display_organization'] = xss_injected_unique_id
self.course_info['display_coursenumber'] = xss_injected_unique_id
self.populate_course_fixture(self.course_fixture)
self.course_fixture.install()
self.user = self.course_fixture.user
......
......@@ -61,8 +61,8 @@ class CourseTeamPageTest(StudioCourseTest):
def check_course_equality(course1, course2):
""" Compares to course dictionaries using org, number and run as keys"""
return (
course1['org'] == course2['org'] and
course1['number'] == course2['number'] and
course1['org'] == course2['display_organization'] and
course1['number'] == course2['display_coursenumber'] and
course1['run'] == course2['run']
)
......
......@@ -19,7 +19,7 @@ class CertificatesTest(StudioCourseTest):
Tests for settings/certificates Page.
"""
def setUp(self): # pylint: disable=arguments-differ
super(CertificatesTest, self).setUp(is_staff=True)
super(CertificatesTest, self).setUp(is_staff=True, test_xss=False)
self.certificates_page = CertificatesPage(
self.browser,
self.course_info['org'],
......
......@@ -18,6 +18,7 @@ from ..pages.lms.auto_auth import AutoAuthPage as LmsAutoAuthPage
from ..tests.lms.test_lms_user_preview import verify_expected_problem_visibility
from bok_choy.promise import EmptyPromise
from bok_choy.page_object import XSS_INJECTION
@attr('shard_5')
......@@ -28,8 +29,8 @@ class EndToEndCohortedCoursewareTest(ContainerBase):
super(EndToEndCohortedCoursewareTest, self).setUp(is_staff=is_staff)
self.staff_user = self.user
self.content_group_a = "Content Group A"
self.content_group_b = "Content Group B"
self.content_group_a = "Content Group A" + XSS_INJECTION
self.content_group_b = "Content Group B" + XSS_INJECTION
# Create a student who will be in "Cohort A"
self.cohort_a_student_username = "cohort_a_student"
......
......@@ -399,7 +399,7 @@ common/test/acceptance/tests. This is another example.
paver test_bokchoy -t studio/test_studio_bad_data.py
To run a single test faster by not repeating setup tasks us the ``--fasttest`` option.
To run a single test faster by not repeating setup tasks use the ``--fasttest`` option.
::
......
......@@ -17,6 +17,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase
from courseware.tabs import get_course_tab_list
from django.conf import settings
from django.core.urlresolvers import reverse, resolve
from django.utils.translation import ugettext as _
from django.utils.timezone import UTC
from django.test.utils import override_settings
from django.test import RequestFactory
......@@ -264,6 +265,29 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
'<form action=".+create_ccx"',
response.content))
def test_create_ccx_with_ccx_connector_set(self):
"""
Assert that coach cannot create ccx when ``ccx_connector`` url is set.
"""
course = CourseFactory.create()
course.ccx_connector = "http://ccx.com"
course.save()
self.store.update_item(course, 0)
role = CourseCcxCoachRole(course.id)
role.add_users(self.coach)
url = reverse(
'create_ccx',
kwargs={'course_id': unicode(course.id)})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
error_message = _(
"A CCX can only be created on this course through an external service."
" Contact a course admin to give you access."
)
self.assertTrue(re.search(error_message, response.content))
def test_create_ccx(self, ccx_name='New CCX'):
"""
Create CCX. Follow redirect to coach dashboard, confirm we see
......
......@@ -12,6 +12,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
from django.core.validators import validate_email
from django.core.urlresolvers import reverse
from courseware.courses import get_course_by_id
from courseware.model_data import FieldDataCache
......@@ -34,6 +35,28 @@ from lms.djangoapps.ccx.custom_exception import CCXUserValidationException
log = logging.getLogger("edx.ccx")
def get_ccx_creation_dict(course):
"""
Return dict of rendering create ccx form.
Arguments:
course (CourseDescriptorWithMixins): An edx course
Returns:
dict: A attribute dict for view rendering
"""
context = {
'course': course,
'create_ccx_url': reverse('create_ccx', kwargs={'course_id': course.id}),
'has_ccx_connector': "true" if hasattr(course, 'ccx_connector') and course.ccx_connector else "false",
'use_ccx_con_error_message': _(
"A CCX can only be created on this course through an external service."
" Contact a course admin to give you access."
)
}
return context
def get_ccx_from_ccx_locator(course_id):
""" helper function to allow querying ccx fields from templates """
ccx_id = getattr(course_id, 'ccx', None)
......
......@@ -58,6 +58,7 @@ from lms.djangoapps.ccx.utils import (
ccx_students_enrolling_center,
get_ccx_for_coach,
get_ccx_by_ccx_id,
get_ccx_creation_dict,
get_date,
parse_date,
prep_course_for_grading,
......@@ -132,6 +133,7 @@ def dashboard(request, course, ccx=None):
'course': course,
'ccx': ccx,
}
context.update(get_ccx_creation_dict(course))
if ccx:
ccx_locator = CCXLocator.from_course_locator(course.id, unicode(ccx.id))
......@@ -168,6 +170,13 @@ def create_ccx(request, course, ccx=None):
"""
name = request.POST.get('name')
if hasattr(course, 'ccx_connector') and course.ccx_connector:
# if ccx connector url is set in course settings then inform user that he can
# only create ccx by using ccx connector url.
context = get_ccx_creation_dict(course)
messages.error(request, context['use_ccx_con_error_message'])
return render_to_response('ccx/coach_dashboard.html', context)
# prevent CCX objects from being created for deprecated course ids.
if course.id.deprecated:
messages.error(request, _(
......
......@@ -4,6 +4,7 @@ import datetime
import json
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from django.test.utils import override_settings
from freezegun import freeze_time
......@@ -99,3 +100,13 @@ class EdxRestApiClientTest(TestCase):
)
actual_object = ecommerce_api_client(self.user).baskets(1).order.get()
self.assertEqual(actual_object, {u"result": u"Préparatoire"})
def test_client_with_user_without_profile(self):
"""
Verify client initialize successfully for users having no profile.
"""
worker = User.objects.create_user(username='test_worker', email='test@example.com')
api_client = ecommerce_api_client(worker)
self.assertEqual(api_client._store['session'].auth.__dict__['username'], worker.username) # pylint: disable=protected-access
self.assertIsNone(api_client._store['session'].auth.__dict__['full_name']) # pylint: disable=protected-access
......@@ -38,26 +38,25 @@ class EcommerceServiceTests(TestCase):
self.request_factory = RequestFactory()
self.user = UserFactory.create()
self.request = self.request_factory.get("foo")
self.request.user = self.user
update_commerce_config(enabled=True)
super(EcommerceServiceTests, self).setUp()
def test_is_enabled(self):
"""Verify that is_enabled() returns True when ecomm checkout is enabled. """
is_enabled = EcommerceService().is_enabled(self.request)
is_enabled = EcommerceService().is_enabled(self.user)
self.assertTrue(is_enabled)
config = CommerceConfiguration.current()
config.checkout_on_ecommerce_service = False
config.save()
is_not_enabled = EcommerceService().is_enabled(self.request)
is_not_enabled = EcommerceService().is_enabled(self.user)
self.assertFalse(is_not_enabled)
@patch('openedx.core.djangoapps.theming.helpers.is_request_in_themed_site')
def test_is_enabled_for_microsites(self, is_microsite):
"""Verify that is_enabled() returns False if used for a microsite."""
is_microsite.return_value = True
is_not_enabled = EcommerceService().is_enabled(self.request)
is_not_enabled = EcommerceService().is_enabled(self.user)
self.assertFalse(is_not_enabled)
@override_settings(ECOMMERCE_PUBLIC_URL_ROOT='http://ecommerce_url')
......
......@@ -44,9 +44,9 @@ class EcommerceService(object):
def __init__(self):
self.config = CommerceConfiguration.current()
def is_enabled(self, request):
def is_enabled(self, user):
""" Check if the user is activated, if the service is enabled and that the site is not a microsite. """
return (request.user.is_active and self.config.checkout_on_ecommerce_service and not
return (user.is_active and self.config.checkout_on_ecommerce_service and not
helpers.is_request_in_themed_site())
def payment_page_url(self):
......
......@@ -14,6 +14,7 @@ from lazy import lazy
import pytz
from course_modes.models import CourseMode
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification
from student.models import CourseEnrollment
......@@ -204,6 +205,12 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
@property
def link(self):
ecommerce_service = EcommerceService()
if ecommerce_service.is_enabled(self.user):
course_mode = CourseMode.objects.get(
course_id=self.course.id, mode_slug=CourseMode.VERIFIED
)
return ecommerce_service.checkout_page_url(course_mode.sku)
return reverse('verify_student_upgrade_and_verify', args=(self.course.id,))
@lazy
......
......@@ -14,17 +14,20 @@ package and is used to wrap the `authored_data` when constructing an
`LmsFieldData`. This means overrides will be in effect for all scopes covered
by `authored_data`, e.g. course content and settings stored in Mongo.
"""
import threading
from abc import ABCMeta, abstractmethod
from contextlib import contextmanager
import threading
from django.conf import settings
from request_cache.middleware import RequestCache
from xblock.field_data import FieldData
from request_cache.middleware import RequestCache
from xmodule.modulestore.inheritance import InheritanceMixin
NOTSET = object()
ENABLED_OVERRIDE_PROVIDERS_KEY = "courseware.field_overrides.enabled_providers.{course_id}"
ENABLED_OVERRIDE_PROVIDERS_KEY = u'courseware.field_overrides.enabled_providers.{course_id}'
ENABLED_MODULESTORE_OVERRIDE_PROVIDERS_KEY = u'courseware.modulestore_field_overrides.enabled_providers.{course_id}'
def resolve_dotted(name):
......@@ -46,6 +49,88 @@ def resolve_dotted(name):
return target
def _lineage(block):
"""
Returns an iterator over all ancestors of the given block, starting with
its immediate parent and ending at the root of the block tree.
"""
parent = block.get_parent()
while parent:
yield parent
parent = parent.get_parent()
class _OverridesDisabled(threading.local):
"""
A thread local used to manage state of overrides being disabled or not.
"""
disabled = ()
_OVERRIDES_DISABLED = _OverridesDisabled()
@contextmanager
def disable_overrides():
"""
A context manager which disables field overrides inside the context of a
`with` statement, allowing code to get at the `original` value of a field.
"""
prev = _OVERRIDES_DISABLED.disabled
_OVERRIDES_DISABLED.disabled += (True,)
yield
_OVERRIDES_DISABLED.disabled = prev
def overrides_disabled():
"""
Checks to see whether overrides are disabled in the current context.
Returns a boolean value. See `disable_overrides`.
"""
return bool(_OVERRIDES_DISABLED.disabled)
class FieldOverrideProvider(object):
"""
Abstract class which defines the interface that a `FieldOverrideProvider`
must provide. In general, providers should derive from this class, but
it's not strictly necessary as long as they correctly implement this
interface.
A `FieldOverrideProvider` implementation is only responsible for looking up
field overrides. To set overrides, there will be a domain specific API for
the concrete override implementation being used.
"""
__metaclass__ = ABCMeta
def __init__(self, user):
self.user = user
@abstractmethod
def get(self, block, name, default): # pragma no cover
"""
Look for an override value for the field named `name` in `block`.
Returns the overridden value or `default` if no override is found.
"""
raise NotImplementedError
@abstractmethod
def enabled_for(self, course): # pragma no cover
"""
Return True if this provider should be enabled for a given course,
and False otherwise.
Concrete implementations are responsible for implementing this method.
Arguments:
course (CourseModule or None)
Returns:
bool
"""
return False
class OverrideFieldData(FieldData):
"""
A :class:`~xblock.field_data.FieldData` which wraps another `FieldData`
......@@ -171,83 +256,54 @@ class OverrideFieldData(FieldData):
return self.fallback.default(block, name)
class _OverridesDisabled(threading.local):
"""
A thread local used to manage state of overrides being disabled or not.
"""
disabled = ()
_OVERRIDES_DISABLED = _OverridesDisabled()
@contextmanager
def disable_overrides():
"""
A context manager which disables field overrides inside the context of a
`with` statement, allowing code to get at the `original` value of a field.
"""
prev = _OVERRIDES_DISABLED.disabled
_OVERRIDES_DISABLED.disabled += (True,)
yield
_OVERRIDES_DISABLED.disabled = prev
def overrides_disabled():
"""
Checks to see whether overrides are disabled in the current context.
Returns a boolean value. See `disable_overrides`.
"""
return bool(_OVERRIDES_DISABLED.disabled)
class OverrideModulestoreFieldData(OverrideFieldData):
"""Apply field data overrides at the modulestore level. No student context required."""
@classmethod
def wrap(cls, block, field_data): # pylint: disable=arguments-differ
"""
Returns an instance of FieldData wrapped by FieldOverrideProviders which
extend read-only functionality. If no MODULESTORE_FIELD_OVERRIDE_PROVIDERS
are configured, an unwrapped FieldData instance is returned.
class FieldOverrideProvider(object):
"""
Abstract class which defines the interface that a `FieldOverrideProvider`
must provide. In general, providers should derive from this class, but
it's not strictly necessary as long as they correctly implement this
interface.
Arguments:
block: An XBlock
field_data: An instance of FieldData to be wrapped
"""
if cls.provider_classes is None:
cls.provider_classes = [
resolve_dotted(name) for name in settings.MODULESTORE_FIELD_OVERRIDE_PROVIDERS
]
A `FieldOverrideProvider` implementation is only responsible for looking up
field overrides. To set overrides, there will be a domain specific API for
the concrete override implementation being used.
"""
__metaclass__ = ABCMeta
enabled_providers = cls._providers_for_block(block)
if enabled_providers:
return cls(field_data, enabled_providers)
def __init__(self, user):
self.user = user
return field_data
@abstractmethod
def get(self, block, name, default): # pragma no cover
"""
Look for an override value for the field named `name` in `block`.
Returns the overridden value or `default` if no override is found.
@classmethod
def _providers_for_block(cls, block):
"""
raise NotImplementedError
Computes a list of enabled providers based on the given XBlock.
The result is cached per request to avoid the overhead incurred
by filtering override providers hundreds of times.
@abstractmethod
def enabled_for(self, course): # pragma no cover
Arguments:
block: An XBlock
"""
Return True if this provider should be enabled for a given course,
and False otherwise.
Concrete implementations are responsible for implementing this method.
course_id = unicode(block.location.course_key)
cache_key = ENABLED_MODULESTORE_OVERRIDE_PROVIDERS_KEY.format(course_id=course_id)
Arguments:
course (CourseModule or None)
request_cache = RequestCache.get_request_cache()
enabled_providers = request_cache.data.get(cache_key)
Returns:
bool
"""
return False
if enabled_providers is None:
enabled_providers = [
provider_class for provider_class in cls.provider_classes if provider_class.enabled_for(block)
]
request_cache.data[cache_key] = enabled_providers
return enabled_providers
def _lineage(block):
"""
Returns an iterator over all ancestors of the given block, starting with
its immediate parent and ending at the root of the block tree.
"""
parent = block.get_parent()
while parent:
yield parent
parent = parent.get_parent()
def __init__(self, fallback, providers):
super(OverrideModulestoreFieldData, self).__init__(None, fallback, providers)
......@@ -20,9 +20,10 @@ class SelfPacedDateOverrideProvider(FieldOverrideProvider):
# Remove release dates for course content
if name == 'start' and block.category != 'course':
return None
return default
@classmethod
def enabled_for(cls, course):
def enabled_for(cls, block):
"""This provider is enabled for self-paced courses only."""
return course is not None and course.self_paced and SelfPacedConfiguration.current().enabled
return block is not None and block.self_paced and SelfPacedConfiguration.current().enabled
......@@ -8,6 +8,7 @@ import freezegun
from nose.plugins.attrib import attr
import pytz
from commerce.models import CommerceConfiguration
from course_modes.tests.factories import CourseModeFactory
from course_modes.models import CourseMode
from courseware.courses import _get_course_date_summary_blocks
......@@ -44,6 +45,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
enrollment_mode=CourseMode.VERIFIED,
days_till_verification_deadline=14,
verification_status=None,
sku=None
):
"""Set up the course and user for this test."""
now = datetime.now(pytz.UTC)
......@@ -61,7 +63,8 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=enrollment_mode,
expiration_datetime=now + timedelta(days=days_till_upgrade_deadline)
expiration_datetime=now + timedelta(days=days_till_upgrade_deadline),
sku=sku
)
CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user, mode=enrollment_mode)
else:
......@@ -200,6 +203,18 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
self.assertIsNone(block.date)
def test_ecommerce_checkout_redirect(self):
"""Verify the block link redirects to ecommerce checkout if it's enabled."""
sku = 'TESTSKU'
checkout_page = '/test_basket/'
CommerceConfiguration.objects.create(
checkout_on_ecommerce_service=True,
single_course_checkout_page=checkout_page
)
self.setup_course_and_user(sku=sku)
block = VerifiedUpgradeDeadlineDate(self.course, self.user)
self.assertEqual(block.link, '{}?sku={}'.format(checkout_page, sku))
## VerificationDeadlineDate
def test_no_verification_deadline(self):
......
"""
Tests for `field_overrides` module.
"""
# pylint: disable=missing-docstring
import unittest
from nose.plugins.attrib import attr
......@@ -10,16 +11,39 @@ from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from ..field_overrides import (
resolve_dotted,
disable_overrides,
FieldOverrideProvider,
OverrideFieldData,
resolve_dotted,
OverrideModulestoreFieldData,
)
TESTUSER = "testuser"
class TestOverrideProvider(FieldOverrideProvider):
"""
A concrete implementation of `FieldOverrideProvider` for testing.
"""
def get(self, block, name, default):
if self.user:
assert self.user is TESTUSER
assert block == 'block'
if name == 'foo':
return 'fu'
elif name == 'oh':
return 'man'
return default
@classmethod
def enabled_for(cls, course):
return True
@attr('shard_1')
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
'courseware.tests.test_field_overrides.TestOverrideProvider',))
......@@ -101,6 +125,31 @@ class OverrideFieldDataTests(SharedModuleStoreTestCase):
@attr('shard_1')
@override_settings(
MODULESTORE_FIELD_OVERRIDE_PROVIDERS=['courseware.tests.test_field_overrides.TestOverrideProvider']
)
class OverrideModulestoreFieldDataTests(OverrideFieldDataTests):
def setUp(self):
super(OverrideModulestoreFieldDataTests, self).setUp()
OverrideModulestoreFieldData.provider_classes = None
def tearDown(self):
super(OverrideModulestoreFieldDataTests, self).tearDown()
OverrideModulestoreFieldData.provider_classes = None
def make_one(self):
return OverrideModulestoreFieldData.wrap(self.course, DictFieldData({
'foo': 'bar',
'bees': 'knees',
}))
@override_settings(MODULESTORE_FIELD_OVERRIDE_PROVIDERS=[])
def test_no_overrides_configured(self):
data = self.make_one()
self.assertIsInstance(data, DictFieldData)
@attr('shard_1')
class ResolveDottedTests(unittest.TestCase):
"""
Tests for `resolve_dotted`.
......@@ -121,24 +170,6 @@ class ResolveDottedTests(unittest.TestCase):
)
class TestOverrideProvider(FieldOverrideProvider):
"""
A concrete implementation of `FieldOverrideProvider` for testing.
"""
def get(self, block, name, default):
assert self.user is TESTUSER
assert block == 'block'
if name == 'foo':
return 'fu'
if name == 'oh':
return 'man'
return default
@classmethod
def enabled_for(cls, course):
return True
def inject_field_overrides(blocks, course, user):
"""
Apparently the test harness doesn't use LmsFieldStorage, and I'm
......
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