Merge branch 'will/third-party-auth-fixes' into will/combine-reg-login-form

Conflicts:
	common/djangoapps/third_party_auth/__init__.py
	common/djangoapps/third_party_auth/pipeline.py
This commit is contained in:
Will Daly
2014-10-28 15:16:27 -04:00
504 changed files with 58408 additions and 15623 deletions

1
.gitignore vendored
View File

@@ -55,6 +55,7 @@ cover_html/
reports/
jscover.log
jscover.log.*
.tddium*
### Installation artifacts
*.egg-info

View File

@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Common: Add configurable reset button to units
LMS: Support adding cohorts from the instructor dashboard. TNL-162
LMS: Support adding students to a cohort via the instructor dashboard. TNL-163

View File

@@ -13,6 +13,7 @@ MAXIMUM_ATTEMPTS = "Maximum Attempts"
PROBLEM_WEIGHT = "Problem Weight"
RANDOMIZATION = 'Randomization'
SHOW_ANSWER = "Show Answer"
SHOW_RESET_BUTTON = "Show Reset Button"
TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts"
MATLAB_API_KEY = "Matlab API key"
@@ -102,6 +103,7 @@ def i_see_advanced_settings_with_values(step):
[PROBLEM_WEIGHT, "", False],
[RANDOMIZATION, "Never", False],
[SHOW_ANSWER, "Finished", False],
[SHOW_RESET_BUTTON, "False", False],
[TIMER_BETWEEN_ATTEMPTS, "0", False],
])

View File

@@ -325,8 +325,7 @@ MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
EMBARGO_SITE_REDIRECT_URL = None
############################### Pipeline #######################################
STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
STATICFILES_STORAGE = 'cms.lib.django_require.staticstorage.OptimizedCachedRequireJsStorage'
from rooted_paths import rooted_glob
@@ -457,6 +456,34 @@ STATICFILES_IGNORE_PATTERNS = (
PIPELINE_YUI_BINARY = 'yui-compressor'
################################# DJANGO-REQUIRE ###############################
# The baseUrl to pass to the r.js optimizer, relative to STATIC_ROOT.
REQUIRE_BASE_URL = "./"
# The name of a build profile to use for your project, relative to REQUIRE_BASE_URL.
# A sensible value would be 'app.build.js'. Leave blank to use the built-in default build profile.
# Set to False to disable running the default profile (e.g. if only using it to build Standalone
# Modules)
REQUIRE_BUILD_PROFILE = "build.js"
# The name of the require.js script used by your project, relative to REQUIRE_BASE_URL.
REQUIRE_JS = "js/vendor/require.js"
# A dictionary of standalone modules to build with almond.js.
REQUIRE_STANDALONE_MODULES = {}
# Whether to run django-require in debug mode.
REQUIRE_DEBUG = False
# A tuple of files to exclude from the compilation result of r.js.
REQUIRE_EXCLUDE = ("build.txt",)
# The execution environment in which to run r.js: auto, node or rhino.
# auto will autodetect the environment and make use of node if available and rhino if not.
# It can also be a path to a custom class that subclasses require.environments.Environment and defines some "args" function that returns a list with the command arguments to execute.
REQUIRE_ENVIRONMENT = "node"
################################# CELERY ######################################
# Message configuration
@@ -563,6 +590,7 @@ INSTALLED_APPS = (
'pipeline',
'staticfiles',
'static_replace',
'require',
# comment common
'django_comment_common',

View File

View File

@@ -0,0 +1,13 @@
"""
:class:`~django_require.staticstorage.OptimizedCachedRequireJsStorage`
"""
from pipeline.storage import PipelineCachedStorage
from require.storage import OptimizedFilesMixin
class OptimizedCachedRequireJsStorage(OptimizedFilesMixin, PipelineCachedStorage):
"""
Custom storage backend that is used by Django-require.
"""
pass

155
cms/static/build.js Normal file
View File

@@ -0,0 +1,155 @@
(function () {
'use strict';
var getModule = function (moduleName, excludeCommonDeps) {
var module = {
name: moduleName
};
if (excludeCommonDeps) {
module.exclude = ['js/factories/common_deps'];
}
return module;
};
var getModulesList = function (modules) {
var result = [getModule('js/factories/common_deps')];
return result.concat(modules.map(function (moduleName) {
return getModule(moduleName, true);
}));
};
return {
/**
* List the modules that will be optimized. All their immediate and deep
* dependencies will be included in the module's file when the build is
* done.
*/
modules: getModulesList([
'js/factories/asset_index',
'js/factories/base',
'js/factories/checklists',
'js/factories/container',
'js/factories/course',
'js/factories/course_create_rerun',
'js/factories/course_info',
'js/factories/edit_tabs',
'js/factories/export',
'js/factories/group_configurations',
'js/factories/import',
'js/factories/index',
'js/factories/login',
'js/factories/manage_users',
'js/factories/outline',
'js/factories/register',
'js/factories/settings',
'js/factories/settings_advanced',
'js/factories/settings_graders',
'js/factories/textbooks'
]),
/**
* By default all the configuration for optimization happens from the command
* line or by properties in the config file, and configuration that was
* passed to requirejs as part of the app's runtime "main" JS file is *not*
* considered. However, if you prefer the "main" JS file configuration
* to be read for the build so that you do not have to duplicate the values
* in a separate configuration, set this property to the location of that
* main JS file. The first requirejs({}), require({}), requirejs.config({}),
* or require.config({}) call found in that file will be used.
* As of 2.1.10, mainConfigFile can be an array of values, with the last
* value's config take precedence over previous values in the array.
*/
mainConfigFile: 'require-config.js',
/**
* Set paths for modules. If relative paths, set relative to baseUrl above.
* If a special value of "empty:" is used for the path value, then that
* acts like mapping the path to an empty file. It allows the optimizer to
* resolve the dependency to path, but then does not include it in the output.
* Useful to map module names that are to resources on a CDN or other
* http: URL when running in the browser and during an optimization that
* file should be skipped because it has no dependencies.
*/
paths: {
'gettext': 'empty:',
'xmodule': 'empty:',
'mathjax': 'empty:',
'tender': 'empty:',
'youtube': 'empty:'
},
/**
* If shim config is used in the app during runtime, duplicate the config
* here. Necessary if shim config is used, so that the shim's dependencies
* are included in the build. Using "mainConfigFile" is a better way to
* pass this information though, so that it is only listed in one place.
* However, if mainConfigFile is not an option, the shim config can be
* inlined in the build config.
*/
shim: {
'xmodule': {
deps: [
'jquery', 'underscore', 'mathjax', 'codemirror', 'tinymce',
'jquery.tinymce', 'jquery.qtip', 'jquery.scrollTo', 'jquery.flot',
'jquery.cookie', 'utility'
]
}
},
/**
* Introduced in 2.1.2: If using "dir" for an output directory, normally the
* optimize setting is used to optimize the build bundles (the "modules"
* section of the config) and any other JS file in the directory. However, if
* the non-build bundle JS files will not be loaded after a build, you can
* skip the optimization of those files, to speed up builds. Set this value
* to true if you want to skip optimizing those other non-build bundle JS
* files.
*/
skipDirOptimize: true,
/**
* When the optimizer copies files from the source location to the
* destination directory, it will skip directories and files that start
* with a ".". If you want to copy .directories or certain .files, for
* instance if you keep some packages in a .packages directory, or copy
* over .htaccess files, you can set this to null. If you want to change
* the exclusion rules, change it to a different regexp. If the regexp
* matches, it means the directory will be excluded. This used to be
* called dirExclusionRegExp before the 1.0.2 release.
* As of 1.0.3, this value can also be a string that is converted to a
* RegExp via new RegExp().
*/
fileExclusionRegExp: /^\.|spec/,
/**
* Allow CSS optimizations. Allowed values:
* - "standard": @import inlining and removal of comments, unnecessary
* whitespace and line returns.
* Removing line returns may have problems in IE, depending on the type
* of CSS.
* - "standard.keepLines": like "standard" but keeps line returns.
* - "none": skip CSS optimizations.
* - "standard.keepComments": keeps the file comments, but removes line
* returns. (r.js 1.0.8+)
* - "standard.keepComments.keepLines": keeps the file comments and line
* returns. (r.js 1.0.8+)
* - "standard.keepWhitespace": like "standard" but keeps unnecessary whitespace.
*/
optimizeCss: 'none',
/**
* How to optimize all the JS files in the build output directory.
* Right now only the following values are supported:
* - "uglify": Uses UglifyJS to minify the code.
* - "uglify2": Uses UglifyJS2.
* - "closure": Uses Google's Closure Compiler in simple optimization
* mode to minify the code. Only available if REQUIRE_ENVIRONMENT is "rhino" (the default).
* - "none": No minification will be done.
*/
optimize: 'uglify2',
/**
* Sets the logging level. It is a number:
* TRACE: 0,
* INFO: 1,
* WARN: 2,
* ERROR: 3,
* SILENT: 4
* Default is 0.
*/
logLevel: 4
};
} ())

View File

@@ -0,0 +1,13 @@
define([
'jquery', 'js/collections/asset', 'js/views/assets', 'jquery.fileupload'
], function($, AssetCollection, AssetsView) {
'use strict';
return function (assetCallbackUrl) {
var assets = new AssetCollection(),
assetsView;
assets.url = assetCallbackUrl;
assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')});
assetsView.render();
};
});

View File

@@ -0,0 +1,2 @@
define(['js/base', 'coffee/src/main', 'coffee/src/logger', 'datepair', 'accessibility',
'ieshim', 'tooltip_manager']);

View File

@@ -0,0 +1,16 @@
define([
'jquery', 'js/collections/checklist', 'js/views/checklist'
], function($, ChecklistCollection, ChecklistView) {
'use strict';
return function (handlerUrl) {
var checklistCollection = new ChecklistCollection(),
editor;
checklistCollection.url = handlerUrl;
editor = new ChecklistView({
el: $('.course-checklists'),
collection: checklistCollection
});
checklistCollection.fetch({reset: true});
};
});

View File

@@ -0,0 +1 @@
define(['domReady!', 'jquery', 'backbone', 'underscore', 'gettext']);

View File

@@ -0,0 +1,23 @@
define([
'jquery', 'js/models/xblock_info', 'js/views/pages/container',
'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson, action, isUnitPage) {
var templates = new ComponentTemplates(componentTemplates, {parse: true}),
mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true});
xmoduleLoader.done(function () {
var view = new ContainerPage({
el: $('#content'),
model: mainXBlockInfo,
action: action,
templates: templates,
isUnitPage: isUnitPage
});
view.render();
});
};
});

View File

@@ -0,0 +1,6 @@
define(['js/models/course'], function(Course) {
'use strict';
return function (courseInfo) {
window.course = new Course(courseInfo);
}
});

View File

@@ -0,0 +1,4 @@
define(['jquery', 'jquery.form', 'js/views/course_rerun'], function ($) {
'use strict';
return function () {};
});

View File

@@ -0,0 +1,25 @@
define([
'jquery', 'js/collections/course_update', 'js/models/module_info',
'js/models/course_info', 'js/views/course_info_edit'
], function($, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) {
'use strict';
return function (updatesUrl, handoutsLocator, baseAssetUrl) {
var course_updates = new CourseUpdateCollection(),
course_handouts, editor;
course_updates.url = updatesUrl;
course_updates.fetch({reset: true});
course_handouts = new ModuleInfoModel({
id: handoutsLocator
});
editor = new CourseInfoEditView({
el: $('.main-wrapper'),
model : new CourseInfoModel({
updates : course_updates,
base_asset_url : baseAssetUrl,
handouts : course_handouts
})
});
editor.render();
};
});

View File

@@ -0,0 +1,20 @@
define([
'js/models/explicit_url', 'coffee/src/views/tabs', 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1'
], function (TabsModel, TabsEditView, xmoduleLoader) {
'use strict';
return function (courseLocation, explicitUrl) {
xmoduleLoader.done(function () {
var model = new TabsModel({
id: courseLocation,
explicit_url: explicitUrl
}),
editView;
editView = new TabsEditView({
el: $('.tab-list'),
model: model,
mast: $('.wrapper-mast')
});
});
};
});

View File

@@ -0,0 +1,57 @@
define(['gettext', 'js/views/feedback_prompt'], function(gettext, PromptView) {
'use strict';
return function (hasUnit, editUnitUrl, courseHomeUrl, errMsg) {
var dialog;
if(hasUnit) {
dialog = new PromptView({
title: gettext('There has been an error while exporting.'),
message: gettext('There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages.'),
intent: 'error',
actions: {
primary: {
text: gettext('Correct failed component'),
click: function(view) {
view.hide();
document.location = editUnitUrl;
}
},
secondary: {
text: gettext('Return to Export'),
click: function(view) {
view.hide();
}
}
}
});
} else {
var msg = '<p>' + gettext('There has been a failure to export your course to XML. Unfortunately, we do not have specific enough information to assist you in identifying the failed component. It is recommended that you inspect your courseware to identify any components in error and try again.') + '</p><p>' + gettext('The raw error message is:') + '</p>' + errMsg;
dialog = new PromptView({
title: gettext('There has been an error with your export.'),
message: msg,
intent: 'error',
actions: {
primary: {
text: gettext('Yes, take me to the main course page'),
click: function(view) {
view.hide();
document.location = courseHomeUrl;
}
},
secondary: {
text: gettext('Cancel'),
click: function(view) {
view.hide();
}
}
}
});
}
// The CSS animation for the dialog relies on the 'js' class
// being on the body. This happens after this JavaScript is executed,
// causing a 'bouncing' of the dialog after it is initially shown.
// As a workaround, add this class first.
$('body').addClass('js');
dialog.show();
};
});

View File

@@ -0,0 +1,16 @@
define([
'js/collections/group_configuration', 'js/views/pages/group_configurations'
], function(GroupConfigurationCollection, GroupConfigurationsPage) {
'use strict';
return function (configurations, groupConfigurationUrl, courseOutlineUrl) {
var collection = new GroupConfigurationCollection(configurations, { parse: true }),
configurationsPage;
collection.url = groupConfigurationUrl;
collection.outlineUrl = courseOutlineUrl;
configurationsPage = new GroupConfigurationsPage({
el: $('#content'),
collection: collection
}).render();
};
});

View File

@@ -0,0 +1,107 @@
define([
'js/views/import', 'jquery', 'gettext', 'jquery.fileupload', 'jquery.cookie'
], function(CourseImport, $, gettext) {
'use strict';
return function (feedbackUrl) {
var bar = $('.progress-bar'),
fill = $('.progress-fill'),
submitBtn = $('.submit-button'),
chooseBtn = $('.choose-file-button'),
defaults = [
gettext('There was an error during the upload process.') + '\n',
gettext('There was an error while unpacking the file.') + '\n',
gettext('There was an error while verifying the file you submitted.') + '\n',
gettext('There was an error while importing the new course to our database.') + '\n'
],
// Display the status of last file upload on page load
lastFileUpload = $.cookie('lastfileupload'),
file;
if (lastFileUpload){
CourseImport.getAndStartUploadFeedback(feedbackUrl.replace('fillerName', lastFileUpload), lastFileUpload);
}
$('#fileupload').fileupload({
dataType: 'json',
type: 'POST',
maxChunkSize: 20 * 1000000, // 20 MB
autoUpload: false,
add: function(e, data) {
CourseImport.clearImportDisplay();
submitBtn.unbind('click');
file = data.files[0];
if (file.name.match(/tar\.gz$/)) {
submitBtn.click(function(event){
event.preventDefault();
$.cookie('lastfileupload', file.name);
submitBtn.hide();
CourseImport.startUploadFeedback();
data.submit().complete(function(result, textStatus, xhr) {
window.onbeforeunload = null;
if (xhr.status != 200) {
var serverMsg, errMsg, stage;
try{
serverMsg = $.parseJSON(result.responseText);
} catch (e) {
return;
}
errMsg = serverMsg.hasOwnProperty('ErrMsg') ? serverMsg.ErrMsg : '' ;
if (serverMsg.hasOwnProperty('Stage')) {
stage = Math.abs(serverMsg.Stage);
CourseImport.stageError(stage, defaults[stage] + errMsg);
}
else {
alert(gettext('Your import has failed.') + '\n\n' + errMsg);
}
chooseBtn.html(gettext('Choose new file')).show();
bar.hide();
}
CourseImport.stopGetStatus = true;
chooseBtn.html(gettext('Choose new file')).show();
bar.hide();
});
});
} else {
data.files = [];
}
},
progressall: function(e, data){
var percentInt = data.loaded / data.total * 100,
percentVal = parseInt(percentInt, 10) + '%',
doneAt;
// Firefox makes ProgressEvent.loaded equal ProgressEvent.total only
// after receiving a response from the server (see Mozilla bug 637002),
// so for Firefox we jump the gun a little.
if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) {
doneAt = 95;
} else {
doneAt = 99;
}
if (percentInt >= doneAt) {
bar.hide();
// Start feedback with delay so that current stage of import properly updates in session
setTimeout(
function () { CourseImport.startServerFeedback(feedbackUrl.replace('fillerName', file.name));},
3000
);
} else {
bar.show();
fill.width(percentVal).html(percentVal);
}
},
done: function(event, data){
bar.hide();
window.onbeforeunload = null;
CourseImport.displayFinishedImport();
},
start: function(event) {
window.onbeforeunload = function() {
return gettext('Your import is in progress; navigating away will abort it.');
};
},
sequentialUploads: true,
notifyOnError: false
});
};
});

View File

@@ -0,0 +1,40 @@
define(['jquery.form', 'js/index'], function() {
'use strict';
return function () {
// showing/hiding creation rights UI
$('.show-creationrights').click(function(e) {
e.preventDefault();
$(this)
.closest('.wrapper-creationrights')
.toggleClass('is-shown')
.find('.ui-toggle-control')
.toggleClass('current');
});
var reloadPage = function () {
location.reload();
};
var showError = function () {
$('#request-coursecreator-submit')
.toggleClass('has-error')
.find('.label')
.text('Sorry, there was error with your request');
$('#request-coursecreator-submit')
.find('.icon-cog')
.toggleClass('icon-spin');
};
$('#request-coursecreator').ajaxForm({
error: showError,
success: reloadPage
});
$('#request-coursecreator-submit').click(function(event){
$(this)
.toggleClass('is-disabled is-submitting')
.find('.label')
.text('Submitting Your Request');
});
};
});

View File

@@ -0,0 +1,43 @@
define(['jquery.cookie', 'utility'], function() {
'use strict';
return function (homepageURL) {
function postJSON(url, data, callback) {
$.ajax({
type:'POST',
url: url,
dataType: 'json',
data: data,
success: callback,
headers : {'X-CSRFToken':$.cookie('csrftoken')}
});
}
$('form#login_form').submit(function(event) {
event.preventDefault();
var submit_data = $('#login_form').serialize();
postJSON('/login_post', submit_data, function(json) {
if(json.success) {
var next = /next=([^&]*)/g.exec(decodeURIComponent(window.location.search));
if (next && next.length > 1 && !isExternal(next[1])) {
location.href = next[1];
} else {
location.href = homepageURL;
}
} else if($('#login_error').length === 0) {
$('#login_form').prepend(
'<div id="login_error" class="message message-status error">' +
json.value +
'</span></div>'
);
$('#login_error').addClass('is-shown');
} else {
$('#login_error')
.stop()
.addClass('is-shown')
.html(json.value);
}
});
});
};
});

View File

@@ -0,0 +1,203 @@
define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt'], function($, _, gettext, PromptView) {
'use strict';
return function (staffEmails, tplUserURL) {
var unknownErrorMessage = gettext('Unknown'),
$createUserForm = $('#create-user-form'),
$createUserFormWrapper = $createUserForm.closest('.wrapper-create-user'),
$cancelButton;
$createUserForm.bind('submit', function(event) {
event.preventDefault();
var email = $('#user-email-input').val().trim(),
url, msg;
if(!email) {
msg = new PromptView.Error({
title: gettext('A valid email address is required'),
message: gettext('You must enter a valid email address in order to add a new team member'),
actions: {
primary: {
text: gettext('Return and add email address'),
click: function(view) {
view.hide();
$('#user-email-input').focus();
}
}
}
});
msg.show();
}
if(_.contains(staffEmails, email)) {
msg = new PromptView.Warning({
title: gettext('Already a course team member'),
message: _.template(
gettext("{email} is already on the “{course}” team. If you're trying to add a new member, please double-check the email address you provided."), {
email: email,
course: course.escape('name')
}, {interpolate: /\{(.+?)\}/g}
),
actions: {
primary: {
text: gettext('Return to team listing'),
click: function(view) {
view.hide();
$('#user-email-input').focus();
}
}
}
});
msg.show();
}
url = tplUserURL.replace('@@EMAIL@@', $('#user-email-input').val().trim());
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
notifyOnError: false,
data: JSON.stringify({role: 'staff'}),
success: function(data) {location.reload();},
error: function(jqXHR, textStatus, errorThrown) {
var message, prompt;
try {
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
} catch (e) {
message = unknownErrorMessage;
}
prompt = new PromptView.Error({
title: gettext('Error adding user'),
message: message,
actions: {
primary: {
text: gettext('OK'),
click: function(view) {
view.hide();
$('#user-email-input').focus();
}
}
}
});
prompt.show();
}
});
});
$cancelButton = $createUserForm.find('.action-cancel');
$cancelButton.bind('click', function(event) {
event.preventDefault();
$('.create-user-button').toggleClass('is-disabled');
$createUserFormWrapper.toggleClass('is-shown');
$('#user-email-input').val('');
});
$('.create-user-button').bind('click', function(event) {
event.preventDefault();
$('.create-user-button').toggleClass('is-disabled');
$createUserFormWrapper.toggleClass('is-shown');
$createUserForm.find('#user-email-input').focus();
});
$('body').bind('keyup', function(event) {
if(event.which == 27) {
$cancelButton.click();
}
});
$('.remove-user').click(function() {
var email = $(this).data('id'),
msg = new PromptView.Warning({
title: gettext('Are you sure?'),
message: _.template(gettext('Are you sure you want to delete {email} from the course team for “{course}”?'), {email: email, course: course.get('name')}, {interpolate: /\{(.+?)\}/g}),
actions: {
primary: {
text: gettext('Delete'),
click: function(view) {
var url = tplUserURL.replace('@@EMAIL@@', email);
view.hide();
$.ajax({
url: url,
type: 'DELETE',
dataType: 'json',
contentType: 'application/json',
notifyOnError: false,
success: function(data) {location.reload();},
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
} catch (e) {
message = unknownErrorMessage;
}
var prompt = new PromptView.Error({
title: gettext('Error removing user'),
message: message,
actions: {
primary: {
text: gettext('OK'),
click: function(view) {
view.hide();
}
}
}
});
prompt.show();
}
});
}
},
secondary: {
text: gettext('Cancel'),
click: function(view) {
view.hide();
}
}
}
});
msg.show();
});
$('.toggle-admin-role').click(function(event) {
event.preventDefault();
var type, url, role;
if($(this).hasClass('add-admin-role')) {
role = 'instructor';
} else {
role = 'staff';
}
url = $(this).closest('li[data-url]').data('url');
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
notifyOnError: false,
data: JSON.stringify({role: role}),
success: function(data) {location.reload();},
error: function(jqXHR, textStatus, errorThrown) {
var message, prompt;
try {
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
} catch (e) {
message = unknownErrorMessage;
}
prompt = new PromptView.Error({
title: gettext("There was an error changing the user's role"),
message: message,
actions: {
primary: {
text: gettext('Try Again'),
click: function(view) {
view.hide();
}
}
}
});
prompt.show();
}
});
});
};
});

View File

@@ -0,0 +1,14 @@
define([
'js/views/pages/course_outline', 'js/models/xblock_outline_info'
], function(CourseOutlinePage, XBlockOutlineInfo) {
'use strict';
return function (XBlockOutlineInfoJson, initialStateJson) {
var courseXBlock = new XBlockOutlineInfo(XBlockOutlineInfoJson, {parse: true}),
view = new CourseOutlinePage({
el: $('#content'),
model: courseXBlock,
initialState: initialStateJson
});
view.render();
};
});

View File

@@ -0,0 +1,33 @@
define(['jquery', 'jquery.cookie'], function($) {
'use strict';
return function () {
$('form :input')
.focus(function() {
$('label[for="' + this.id + '"]').addClass('is-focused');
})
.blur(function() {
$('label').removeClass('is-focused');
});
$('form#register_form').submit(function(event) {
event.preventDefault();
var submit_data = $('#register_form').serialize();
$.ajax({
url: '/create_account',
type: 'POST',
dataType: 'json',
headers: {'X-CSRFToken': $.cookie('csrftoken')},
notifyOnError: false,
data: submit_data,
success: function(json) {
location.href = '/course/';
},
error: function(jqXHR, textStatus, errorThrown) {
var json = $.parseJSON(jqXHR.responseText);
$('#register_error').html(json.value).stop().addClass('is-shown');
}
});
});
};
});

View File

@@ -0,0 +1,29 @@
define([
'jquery', 'js/models/settings/course_details', 'js/views/settings/main'
], function($, CourseDetailsModel, MainView) {
'use strict';
return function (detailsUrl) {
var model;
// highlighting labels when fields are focused in
$('form :input')
.focus(function() {
$('label[for="' + this.id + '"]').addClass('is-focused');
})
.blur(function() {
$('label').removeClass('is-focused');
});
model = new CourseDetailsModel();
model.urlRoot = detailsUrl;
model.fetch({
success: function(model) {
var editor = new MainView({
el: $('.settings-details'),
model: model
});
editor.render();
},
reset: true
});
};
});

View File

@@ -0,0 +1,44 @@
define([
'jquery', 'gettext', 'js/models/settings/advanced', 'js/views/settings/advanced'
], function($, gettext, AdvancedSettingsModel, AdvancedSettingsView) {
'use strict';
return function (advancedDict, advancedSettingsUrl) {
var advancedModel, editor;
$('form :input')
.focus(function() {
$('label[for="' + this.id + '"]').addClass('is-focused');
})
.blur(function() {
$('label').removeClass('is-focused');
});
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
advancedModel = new AdvancedSettingsModel(advancedDict, {parse: true});
advancedModel.url = advancedSettingsUrl;
editor = new AdvancedSettingsView({
el: $('.settings-advanced'),
model: advancedModel
});
editor.render();
$('#deprecated-settings').click(function() {
var wrapperDeprecatedSetting = $('.wrapper-deprecated-setting'),
deprecatedSettingsLabel = $('.deprecated-settings-label');
if ($(this).is(':checked')) {
wrapperDeprecatedSetting.addClass('is-set');
deprecatedSettingsLabel.text(gettext('Hide Deprecated Settings'));
editor.render_deprecated = true;
}
else {
wrapperDeprecatedSetting.removeClass('is-set');
deprecatedSettingsLabel.text(gettext('Show Deprecated Settings'));
editor.render_deprecated = false;
}
editor.render();
});
};
});

View File

@@ -0,0 +1,24 @@
define([
'jquery', 'js/views/settings/grading', 'js/models/settings/course_grading_policy'
], function($, GradingView, CourseGradingPolicyModel) {
'use strict';
return function (courseDetails, gradingUrl) {
var model, editor;
$('form :input')
.focus(function() {
$('label[for="' + this.id + '"]').addClass('is-focused');
})
.blur(function() {
$('label').removeClass('is-focused');
});
model = new CourseGradingPolicyModel(courseDetails,{parse:true});
model.urlRoot = gradingUrl;
editor = new GradingView({
el: $('.settings-grading'),
model : model
});
editor.render();
};
});

View File

@@ -0,0 +1,20 @@
define([
'gettext', 'js/models/section', 'js/collections/textbook', 'js/views/list_textbooks'
], function(gettext, Section, TextbookCollection, ListTextbooksView) {
'use strict';
return function (textbooksJson) {
var textbooks = new TextbookCollection(textbooksJson, {parse: true}),
tbView = new ListTextbooksView({collection: textbooks});
$('.content-primary').append(tbView.render().el);
$('.nav-actions .new-button').click(function(event) {
tbView.addOne(event);
});
$(window).on('beforeunload', function() {
var dirty = textbooks.find(function(textbook) { return textbook.isDirty(); });
if(dirty) {
return gettext('You have unsaved changes. Do you really want to leave this page?');
}
});
};
});

View File

@@ -68,6 +68,7 @@ src_paths:
- coffee/src
- js
- js/common_helpers
- js/factories
# Paths to spec (test) JavaScript files
spec_paths:

View File

@@ -0,0 +1,266 @@
require.config({
// NOTE: baseUrl has been previously set in cms/static/templates/base.html
waitSeconds: 60,
paths: {
"domReady": "js/vendor/domReady",
"gettext": "/i18n",
"mustache": "js/vendor/mustache",
"codemirror": "js/vendor/codemirror-compressed",
"codemirror/stex": "js/vendor/CodeMirror/stex",
"jquery": "js/vendor/jquery.min",
"jquery.ui": "js/vendor/jquery-ui.min",
"jquery.form": "js/vendor/jquery.form",
"jquery.markitup": "js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "js/vendor/jquery.leanModal.min",
"jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue",
"jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min",
"jquery.timepicker": "js/vendor/timepicker/jquery.timepicker",
"jquery.cookie": "js/vendor/jquery.cookie",
"jquery.qtip": "js/vendor/jquery.qtip.min",
"jquery.scrollTo": "js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.flot": "js/vendor/flot/jquery.flot.min",
"jquery.fileupload": "js/vendor/jQuery-File-Upload/js/jquery.fileupload",
"jquery.iframe-transport": "js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "js/vendor/html5-input-polyfills/number-polyfill",
"jquery.immediateDescendents": "coffee/src/jquery.immediateDescendents",
"datepair": "js/vendor/timepicker/datepair",
"date": "js/vendor/date",
"underscore": "js/vendor/underscore-min",
"underscore.string": "js/vendor/underscore.string.min",
"backbone": "js/vendor/backbone-min",
"backbone.associations": "js/vendor/backbone-associations-min",
"backbone.paginator": "js/vendor/backbone.paginator.min",
"tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min",
"jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min",
"xmodule": "/xmodule/xmodule",
"xblock": "coffee/src/xblock",
"utility": "js/src/utility",
"accessibility": "js/src/accessibility_tools",
"draggabilly": "js/vendor/draggabilly.pkgd",
"URI": "js/vendor/URI.min",
"ieshim": "js/src/ie_shim",
"tooltip_manager": "js/src/tooltip_manager",
// Files needed for Annotations feature
"annotator": "js/vendor/ova/annotator-full",
"annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth",
"video.dev": "js/vendor/ova/video.dev",
"vjs.youtube": 'js/vendor/ova/vjs.youtube',
"rangeslider": 'js/vendor/ova/rangeslider',
"share-annotator": 'js/vendor/ova/share-annotator',
"richText-annotator": 'js/vendor/ova/richText-annotator',
"reply-annotator": 'js/vendor/ova/reply-annotator',
"grouping-annotator": 'js/vendor/ova/grouping-annotator',
"tags-annotator": 'js/vendor/ova/tags-annotator',
"diacritic-annotator": 'js/vendor/ova/diacritic-annotator',
"flagging-annotator": 'js/vendor/ova/flagging-annotator',
"jquery-Watch": 'js/vendor/ova/jquery-Watch',
"openseadragon": 'js/vendor/ova/openseadragon',
"osda": 'js/vendor/ova/OpenSeaDragonAnnotation',
"ova": 'js/vendor/ova/ova',
"catch": 'js/vendor/ova/catch/js/catch',
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2',
// end of Annotation tool files
// externally hosted files
"tender": [
"//edxedge.tenderapp.com/tender_widget",
// if tender fails to load, fallback on a local file
// so that require doesn't fall over
"js/src/tender_fallback"
],
"mathjax": "//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured",
"youtube": [
// youtube URL does not end in ".js". We add "?noext" to the path so
// that require.js adds the ".js" to the query component of the URL,
// and leaves the path component intact.
"//www.youtube.com/player_api?noext",
// if youtube fails to load, fallback on a local file
// so that require doesn't fall over
"js/src/youtube_fallback"
]
},
shim: {
"gettext": {
exports: "gettext"
},
"date": {
exports: "Date"
},
"jquery.ui": {
deps: ["jquery"],
exports: "jQuery.ui"
},
"jquery.form": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxForm"
},
"jquery.markitup": {
deps: ["jquery"],
exports: "jQuery.fn.markitup"
},
"jquery.leanmodal": {
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
},
"jquery.ajaxQueue": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxQueue"
},
"jquery.smoothScroll": {
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
},
"jquery.cookie": {
deps: ["jquery"],
exports: "jQuery.fn.cookie"
},
"jquery.qtip": {
deps: ["jquery"],
exports: "jQuery.fn.qtip"
},
"jquery.scrollTo": {
deps: ["jquery"],
exports: "jQuery.fn.scrollTo",
},
"jquery.flot": {
deps: ["jquery"],
exports: "jQuery.fn.plot"
},
"jquery.fileupload": {
deps: ["jquery.iframe-transport"],
exports: "jQuery.fn.fileupload"
},
"jquery.inputnumber": {
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
},
"datepair": {
deps: ["jquery.ui", "jquery.timepicker"]
},
"underscore": {
exports: "_"
},
"backbone": {
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"backbone.associations": {
deps: ["backbone"],
exports: "Backbone.Associations"
},
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"tender": {
exports: 'Tender'
},
"youtube": {
exports: "YT"
},
"codemirror": {
exports: "CodeMirror"
},
"codemirror/stex": {
deps: ["codemirror"]
},
"tinymce": {
exports: "tinymce"
},
"mathjax": {
exports: "MathJax",
init: function() {
MathJax.Hub.Config({
tex2jax: {
inlineMath: [
["\\(","\\)"],
['[mathjaxinline]','[/mathjaxinline]']
],
displayMath: [
["\\[","\\]"],
['[mathjax]','[/mathjax]']
]
}
});
MathJax.Hub.Configured();
}
},
"URI": {
exports: "URI"
},
"tooltip_manager": {
deps: ["jquery", "underscore"]
},
"jquery.immediateDescendents": {
deps: ["jquery"]
},
"xblock/core": {
exports: "XBlock",
deps: ["jquery", "jquery.immediateDescendents"]
},
"xblock/runtime.v1": {
exports: "XBlock",
deps: ["xblock/core"]
},
"coffee/src/main": {
deps: ["coffee/src/ajax_prefix"]
},
"coffee/src/logger": {
exports: "Logger",
deps: ["coffee/src/ajax_prefix"]
},
// the following are all needed for annotation tools
"video.dev": {
exports:"videojs"
},
"vjs.youtube": {
deps: ["video.dev"]
},
"rangeslider": {
deps: ["video.dev"]
},
"annotator": {
exports: "Annotator"
},
"annotator-harvardx":{
deps: ["annotator"]
},
"share-annotator": {
deps: ["annotator"]
},
"richText-annotator": {
deps: ["annotator", "tinymce"]
},
"reply-annotator": {
deps: ["annotator"]
},
"tags-annotator": {
deps: ["annotator"]
},
"diacritic-annotator": {
deps: ["annotator"]
},
"flagging-annotator": {
deps: ["annotator"]
},
"grouping-annotator": {
deps: ["annotator"]
},
"ova":{
exports: "ova",
deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "jquery-Watch", "catch", "handlebars", "URI"]
},
"osda":{
exports: "osda",
deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "openseadragon", "jquery-Watch", "catch", "handlebars", "URI"]
},
// end of annotation tool files
}
});

View File

@@ -201,7 +201,7 @@
> .label {
display: inline-block;
max-width: 85%;
max-width: 84%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;

View File

@@ -17,18 +17,10 @@
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["jquery", "js/collections/asset", "js/views/assets", "jquery.fileupload"],
function($, AssetCollection, AssetsView) {
var assets = new AssetCollection();
assets.url = "${asset_callback_url}";
var assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')});
assetsView.render();
}); // end of require()
</script>
<%block name="requirejs">
require(["js/factories/asset_index"], function (AssetIndexFactory) {
AssetIndexFactory("${asset_callback_url}");
});
</%block>
<%block name="content">
@@ -72,9 +64,9 @@
</div>
<div class="bit">
<h3 class="title-3">${_("Using File URLs")}</h3>
<p>${_("Use the {em_start}Embed URL{em_end} value to link to the file or image from a component, a course update, or a course handout.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("Use the {em_start}External URL{em_end} value to reference the file or image only from outside of your course.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("Click in the Embed URL or External URL column to select the value, then copy it.")}</p>
</div>

View File

@@ -46,305 +46,17 @@
<a class="nav-skip" href="#content">${_("Skip to this view's content")}</a>
<script type="text/javascript">
window.baseUrl = "${settings.STATIC_URL}";
var require = {
baseUrl: baseUrl,
waitSeconds: 60,
paths: {
"domReady": "js/vendor/domReady",
"gettext": "/i18n",
"mustache": "js/vendor/mustache",
"codemirror": "js/vendor/codemirror-compressed",
"codemirror/stex": "js/vendor/CodeMirror/stex",
"jquery": "js/vendor/jquery.min",
"jquery.ui": "js/vendor/jquery-ui.min",
"jquery.form": "js/vendor/jquery.form",
"jquery.markitup": "js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "js/vendor/jquery.leanModal.min",
"jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue",
"jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min",
"jquery.timepicker": "js/vendor/timepicker/jquery.timepicker",
"jquery.cookie": "js/vendor/jquery.cookie",
"jquery.qtip": "js/vendor/jquery.qtip.min",
"jquery.scrollTo": "js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.flot": "js/vendor/flot/jquery.flot.min",
"jquery.fileupload": "js/vendor/jQuery-File-Upload/js/jquery.fileupload",
"jquery.iframe-transport": "js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "js/vendor/html5-input-polyfills/number-polyfill",
"jquery.immediateDescendents": "coffee/src/jquery.immediateDescendents",
"datepair": "js/vendor/timepicker/datepair",
"date": "js/vendor/date",
"underscore": "js/vendor/underscore-min",
"underscore.string": "js/vendor/underscore.string.min",
"backbone": "js/vendor/backbone-min",
"backbone.associations": "js/vendor/backbone-associations-min",
"backbone.paginator": "js/vendor/backbone.paginator.min",
"tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min",
"jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min",
"xmodule": "/xmodule/xmodule",
"xblock": "coffee/src/xblock",
"utility": "js/src/utility",
"accessibility": "js/src/accessibility_tools",
"draggabilly": "js/vendor/draggabilly.pkgd",
"URI": "js/vendor/URI.min",
"ieshim": "js/src/ie_shim",
"tooltip_manager": "js/src/tooltip_manager",
// Files needed for Annotations feature
"annotator": "js/vendor/ova/annotator-full",
"annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth",
"video.dev": "js/vendor/ova/video.dev",
"vjs.youtube": 'js/vendor/ova/vjs.youtube',
"rangeslider": 'js/vendor/ova/rangeslider',
"share-annotator": 'js/vendor/ova/share-annotator',
"richText-annotator": 'js/vendor/ova/richText-annotator',
"reply-annotator": 'js/vendor/ova/reply-annotator',
"grouping-annotator": 'js/vendor/ova/grouping-annotator',
"tags-annotator": 'js/vendor/ova/tags-annotator',
"diacritic-annotator": 'js/vendor/ova/diacritic-annotator',
"flagging-annotator": 'js/vendor/ova/flagging-annotator',
"jquery-Watch": 'js/vendor/ova/jquery-Watch',
"openseadragon": 'js/vendor/ova/openseadragon',
"osda": 'js/vendor/ova/OpenSeaDragonAnnotation',
"ova": 'js/vendor/ova/ova',
"catch": 'js/vendor/ova/catch/js/catch',
"handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2',
// end of Annotation tool files
// externally hosted files
"tender": [
"//edxedge.tenderapp.com/tender_widget",
// if tender fails to load, fallback on a local file
// so that require doesn't fall over
"js/src/tender_fallback"
],
"mathjax": "//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured",
"youtube": [
// youtube URL does not end in ".js". We add "?noext" to the path so
// that require.js adds the ".js" to the query component of the URL,
// and leaves the path component intact.
"//www.youtube.com/player_api?noext",
// if youtube fails to load, fallback on a local file
// so that require doesn't fall over
"js/src/youtube_fallback"
]
},
shim: {
"gettext": {
exports: "gettext"
},
"date": {
exports: "Date"
},
"jquery.ui": {
deps: ["jquery"],
exports: "jQuery.ui"
},
"jquery.form": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxForm"
},
"jquery.markitup": {
deps: ["jquery"],
exports: "jQuery.fn.markitup"
},
"jquery.leanmodal": {
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
},
"jquery.ajaxQueue": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxQueue"
},
"jquery.smoothScroll": {
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
},
"jquery.cookie": {
deps: ["jquery"],
exports: "jQuery.fn.cookie"
},
"jquery.qtip": {
deps: ["jquery"],
exports: "jQuery.fn.qtip"
},
"jquery.scrollTo": {
deps: ["jquery"],
exports: "jQuery.fn.scrollTo",
},
"jquery.flot": {
deps: ["jquery"],
exports: "jQuery.fn.plot"
},
"jquery.fileupload": {
deps: ["jquery.iframe-transport"],
exports: "jQuery.fn.fileupload"
},
"jquery.inputnumber": {
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
},
"datepair": {
deps: ["jquery.ui", "jquery.timepicker"]
},
"underscore": {
exports: "_"
},
"backbone": {
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"backbone.associations": {
deps: ["backbone"],
exports: "Backbone.Associations"
},
"backbone.paginator": {
deps: ["backbone"],
exports: "Backbone.Paginator"
},
"tender": {
exports: 'Tender'
},
"youtube": {
exports: "YT"
},
"codemirror": {
exports: "CodeMirror"
},
"codemirror/stex": {
deps: ["codemirror"]
},
"tinymce": {
exports: "tinymce"
},
"mathjax": {
exports: "MathJax",
init: function() {
MathJax.Hub.Config({
tex2jax: {
inlineMath: [
["\\(","\\)"],
['[mathjaxinline]','[/mathjaxinline]']
],
displayMath: [
["\\[","\\]"],
['[mathjax]','[/mathjax]']
]
}
});
MathJax.Hub.Configured();
}
},
"URI": {
exports: "URI"
},
"tooltip_manager": {
deps: ["jquery", "underscore"]
},
"jquery.immediateDescendents": {
deps: ["jquery"]
},
"xblock/core": {
exports: "XBlock",
deps: ["jquery", "jquery.immediateDescendents"]
},
"xblock/runtime.v1": {
exports: "XBlock",
deps: ["xblock/core"]
},
"coffee/src/main": {
deps: ["coffee/src/ajax_prefix"]
},
"coffee/src/logger": {
exports: "Logger",
deps: ["coffee/src/ajax_prefix"]
},
// the following are all needed for annotation tools
"video.dev": {
exports:"videojs"
},
"vjs.youtube": {
deps: ["video.dev"]
},
"rangeslider": {
deps: ["video.dev"]
},
"annotator": {
exports: "Annotator"
},
"annotator-harvardx":{
deps: ["annotator"]
},
"share-annotator": {
deps: ["annotator"]
},
"richText-annotator": {
deps: ["annotator", "tinymce"]
},
"reply-annotator": {
deps: ["annotator"]
},
"tags-annotator": {
deps: ["annotator"]
},
"diacritic-annotator": {
deps: ["annotator"]
},
"flagging-annotator": {
deps: ["annotator"]
},
"grouping-annotator": {
deps: ["annotator"]
},
"ova":{
exports: "ova",
deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "jquery-Watch", "catch", "handlebars", "URI"]
},
"osda":{
exports: "osda",
deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "openseadragon", "jquery-Watch", "catch", "handlebars", "URI"]
},
// end of annotation tool files
},
// load jquery and gettext automatically
deps: ["jquery", "gettext"],
callback: function() {
// load other scripts on every page, after jquery loads
require(["js/base", "coffee/src/main", "coffee/src/logger", "datepair", "accessibility", "ieshim", "tooltip_manager"]);
// we need "datepair" because it dynamically modifies the page
// when it is loaded -- yuck!
}
};
window.baseUrl = "${settings.STATIC_URL}";
var require = {baseUrl: window.baseUrl};
</script>
<script type="text/javascript" src="${static.url("js/vendor/require.js")}"></script>
<script type="text/javascript" src="${static.url("require-config.js")}"></script>
## js templates
<script id="system-feedback-tpl" type="text/template">
<%static:include path="js/system-feedback.underscore" />
</script>
% if context_course:
<script type="text/javascript">
require(['js/models/course'], function(Course) {
window.course = new Course({
id: "${context_course.id | escapejs}",
name: "${context_course.display_name_with_default | h}",
url_name: "${context_course.location.name | h}",
org: "${context_course.location.org | h}",
num: "${context_course.location.course | h}",
revision: "${context_course.location.revision | h}"
});
});
</script>
% endif
<!-- view -->
<div class="wrapper wrapper-view" dir="${dir_rtl}">
<% online_help_token = self.online_help_token() if hasattr(self, 'online_help_token') else None %>
@@ -359,12 +71,8 @@
</div>
% if user.is_authenticated():
<script type="text/javascript">
require(['js/sock']);
</script>
<%include file="widgets/sock.html" args="online_help_token=online_help_token" />
<%include file="widgets/sock.html" args="online_help_token=online_help_token" />
% endif
<%include file="widgets/footer.html" />
<%include file="widgets/tender.html" />
@@ -373,6 +81,28 @@
<div id="page-prompt"></div>
<%block name="jsextra"></%block>
<script type="text/javascript">
require(['js/factories/common_deps'], function () {
require(['js/factories/base'], function () {
% if context_course:
require(['js/factories/course'], function(CourseFactory) {
CourseFactory({
id: "${context_course.id | escapejs}",
name: "${context_course.display_name_with_default | h}",
url_name: "${context_course.location.name | h}",
org: "${context_course.location.org | h}",
num: "${context_course.location.course | h}",
revision: "${context_course.location.revision | h}"
});
});
% endif
% if user.is_authenticated():
require(['js/sock']);
% endif
<%block name='requirejs'></%block>
});
});
</script>
<div class="modal-cover"></div>
</body>
</html>

View File

@@ -17,20 +17,10 @@
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "jquery", "js/collections/checklist", "js/views/checklist"],
function(doc, $, ChecklistCollection, ChecklistView) {
var checklistCollection = new ChecklistCollection();
checklistCollection.url = "${handler_url}";
var editor = new ChecklistView({
el: $('.course-checklists'),
collection: checklistCollection
});
checklistCollection.fetch({reset: true});
});
</script>
<%block name="requirejs">
require(["js/factories/checklists"], function (ChecklistsFactory) {
ChecklistsFactory("${handler_url}");
});
</%block>

View File

@@ -34,28 +34,13 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
</%block>
<%block name="jsextra">
<script type='text/javascript'>
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
var mainXBlockInfo = new XBlockInfo(${json.dumps(xblock_info) | n}, {parse: true});
var isUnitPage = ${json.dumps(is_unit_page)}
xmoduleLoader.done(function () {
var view = new ContainerPage({
el: $('#content'),
model: mainXBlockInfo,
action: "${action}",
templates: templates,
isUnitPage: isUnitPage
});
view.render();
});
});
</script>
<%block name="requirejs">
require(["js/factories/container"], function(ContainerFactory) {
ContainerFactory(
${component_templates | n}, ${json.dumps(xblock_info) | n},
"${action}", ${json.dumps(is_unit_page)}
);
});
</%block>
<%block name="content">

View File

@@ -8,15 +8,14 @@
<%block name="bodyclass">is-signedin view-course-create view-course-create-rerun</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "jquery", "jquery.form", "js/views/course_rerun"], function(doc, $) {
});
</script>
<script type="text/javascript">
var source_course_key = "${source_course_key | escapejs}"
</script>
<script type="text/javascript">
var source_course_key = "${source_course_key | escapejs}";
</script>
</%block>
<%block name="requirejs">
require(["js/factories/course_create_rerun"], function (CourseCreateRerunFactory) {
CourseCreateRerunFactory();
});
</%block>
<%block name="content">

View File

@@ -19,30 +19,10 @@
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript" charset="utf-8">
require(["domReady!", "jquery", "js/collections/course_update", "js/models/module_info", "js/models/course_info", "js/views/course_info_edit"],
function(doc, $, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) {
var course_updates = new CourseUpdateCollection();
course_updates.url = '${updates_url}';
course_updates.fetch({reset: true});
var course_handouts = new ModuleInfoModel({
id: '${handouts_locator | escapejs}'
});
var editor = new CourseInfoEditView({
el: $('.main-wrapper'),
model : new CourseInfoModel({
updates : course_updates,
base_asset_url : '${base_asset_url}',
handouts : course_handouts
})
});
editor.render();
});
</script>
<%block name="requirejs">
require(["js/factories/course_info"], function(CourseInfoFactory) {
CourseInfoFactory("${updates_url}", "${handouts_locator | escapejs}", "${base_asset_url}");
});
</%block>
<%block name="content">

View File

@@ -12,19 +12,10 @@ from contentstore.utils import reverse_usage_url
<%namespace name='static' file='static_content.html'/>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "jquery", "js/views/pages/course_outline", "js/models/xblock_outline_info"],
function(doc, $, CourseOutlinePage, XBlockOutlineInfo) {
var courseXBlock = new XBlockOutlineInfo(${json.dumps(course_structure) | n}, { parse: true });
var view = new CourseOutlinePage({
el: $('#content'),
model: courseXBlock,
initialState: ${json.dumps(initial_state) | n}
});
view.render();
});
</script>
<%block name="requirejs">
require(["js/factories/outline"], function (OutlineFactory) {
OutlineFactory(${json.dumps(course_structure) | n}, ${json.dumps(initial_state) | n});
});
</%block>
<%block name="header_extras">

View File

@@ -18,25 +18,10 @@
% endfor
</%block>
<%block name="jsextra">
<script type='text/javascript'>
require(["js/models/explicit_url", "coffee/src/views/tabs",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function (TabsModel, TabsEditView, xmoduleLoader) {
xmoduleLoader.done(function () {
var model = new TabsModel({
id: "${context_course.location | escapejs}",
explicit_url: "${reverse('contentstore.views.tabs_handler', kwargs={'course_key_string': context_course.id})}"
});
new TabsEditView({
el: $('.tab-list'),
model: model,
mast: $('.wrapper-mast')
});
});
});
</script>
<%block name="requirejs">
require(["js/factories/edit_tabs"], function (EditTabsFactory) {
EditTabsFactory("${context_course.location | escapejs}", "${reverse('contentstore.views.tabs_handler', kwargs={'course_key_string': context_course.id})}");
});
</%block>
<%block name="content">

View File

@@ -9,71 +9,17 @@
<%block name="title">${_("Course Export")}</%block>
<%block name="bodyclass">is-signedin course tools view-export</%block>
<%block name="jsextra">
% if in_err:
<script type='text/javascript'>
var hasUnit = ${json.dumps(bool(unit))},
editUnitUrl = "${edit_unit_url or ""}",
courseHomeUrl = "${course_home_url or ""}",
errMsg = ${json.dumps(raw_err_msg or "")};
<%block name="requirejs">
% if in_err:
var hasUnit = ${json.dumps(bool(unit))},
editUnitUrl = "${edit_unit_url or ""}",
courseHomeUrl = "${course_home_url or ""}",
errMsg = ${json.dumps(raw_err_msg or "")};
require(["domReady!", "gettext", "js/views/feedback_prompt"], function(doc, gettext, PromptView) {
var dialog;
if(hasUnit) {
dialog = new PromptView({
title: gettext('There has been an error while exporting.'),
message: gettext("There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages."),
intent: "error",
actions: {
primary: {
text: gettext('Correct failed component'),
click: function(view) {
view.hide();
document.location = editUnitUrl;
}
},
secondary: {
text: gettext('Return to Export'),
click: function(view) {
view.hide();
}
}
}
});
} else {
var msg = "<p>" + gettext("There has been a failure to export your course to XML. Unfortunately, we do not have specific enough information to assist you in identifying the failed component. It is recommended that you inspect your courseware to identify any components in error and try again.") + "</p><p>" + gettext("The raw error message is:") + "</p>" + errMsg;
dialog = new PromptView({
title: gettext('There has been an error with your export.'),
message: msg,
intent: "error",
actions: {
primary: {
text: gettext('Yes, take me to the main course page'),
click: function(view) {
view.hide();
document.location = courseHomeUrl;
}
},
secondary: {
text: gettext('Cancel'),
click: function(view) {
view.hide();
}
}
}
});
}
// The CSS animation for the dialog relies on the 'js' class
// being on the body. This happens after this JavaScript is executed,
// causing a "bouncing" of the dialog after it is initially shown.
// As a workaround, add this class first.
$('body').addClass('js');
dialog.show();
});
</script>
%endif
require(["js/factories/export"], function(ExportFactory) {
ExportFactory(hasUnit, editUnitUrl, courseHomeUrl, errMsg);
});
%endif
</%block>
<%block name="content">

View File

@@ -18,22 +18,12 @@
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "js/collections/group_configuration", "js/views/pages/group_configurations"],
function(doc, GroupConfigurationCollection, GroupConfigurationsPage) {
% if configurations is not None:
var collection = new GroupConfigurationCollection(${json.dumps(configurations)}, { parse: true });
collection.url = "${group_configuration_url}";
collection.outlineUrl = "${course_outline_url}";
new GroupConfigurationsPage({
el: $('#content'),
collection: collection
}).render();
% endif
});
</script>
<%block name="requirejs">
% if configurations is not None:
require(["js/factories/group_configurations"], function(GroupConfigurationsFactory) {
GroupConfigurationsFactory(${json.dumps(configurations)}, "${group_configuration_url}", "${course_outline_url}");
});
% endif
</%block>
<%block name="content">

View File

@@ -147,127 +147,8 @@
</div>
</%block>
<%block name="jsextra">
<script>
require(
["js/views/import", "jquery", "gettext", "jquery.fileupload", "jquery.cookie"],
function(CourseImport, $, gettext) {
var file;
var bar = $('.progress-bar');
var fill = $('.progress-fill');
var percent = $('.percent');
var status = $('#status');
var statusBlock = $('.status-block');
var submitBtn = $('.submit-button');
var chooseBtn = $('.choose-file-button');
var allStats = $('#status-infos');
var feedbackUrl = "${import_status_url}";
var defaults = [
"${_("There was an error during the upload process.")}\n",
"${_("There was an error while unpacking the file.")}\n",
"${_("There was an error while verifying the file you submitted.")}\n",
"${_("There was an error while importing the new course to our database.")}\n"
];
// Display the status of last file upload on page load
var lastfileupload = $.cookie('lastfileupload');
if (lastfileupload){
CourseImport.getAndStartUploadFeedback(feedbackUrl.replace('fillerName', lastfileupload), lastfileupload);
}
$('#fileupload').fileupload({
dataType: 'json',
type: 'POST',
maxChunkSize: 20 * 1000000, // 20 MB
autoUpload: false,
add: function(e, data) {
CourseImport.clearImportDisplay();
submitBtn.unbind('click');
file = data.files[0];
if (file.name.match(/tar\.gz$/)) {
submitBtn.click(function(e){
$.cookie('lastfileupload', file.name);
e.preventDefault();
submitBtn.hide();
CourseImport.startUploadFeedback();
data.submit().complete(function(result, textStatus, xhr) {
window.onbeforeunload = null;
if (xhr.status != 200) {
try{
var serverMsg = $.parseJSON(result.responseText);
} catch (e) {
return;
}
var serverMsg = $.parseJSON(result.responseText);
var errMsg = serverMsg.hasOwnProperty("ErrMsg") ? serverMsg.ErrMsg : "" ;
if (serverMsg.hasOwnProperty("Stage")) {
var stage = Math.abs(serverMsg.Stage);
CourseImport.stageError(stage, defaults[stage] + errMsg);
}
else {
alert("${_("Your import has failed.")}\n\n" + errMsg);
}
chooseBtn.html("${_("Choose new file")}").show();
bar.hide();
}
CourseImport.stopGetStatus = true;
chooseBtn.html("${_("Choose new file")}").show();
bar.hide();
});
});
} else {
data.files = [];
}
},
progressall: function(e, data){
var doneAt;
var percentInt = data.loaded / data.total * 100
var percentVal = parseInt(percentInt, 10) + "%";
// Firefox makes ProgressEvent.loaded equal ProgressEvent.total only
// after receiving a response from the server (see Mozilla bug 637002),
// so for Firefox we jump the gun a little.
if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) {
doneAt = 95;
} else {
doneAt = 99;
}
if (percentInt >= doneAt) {
bar.hide();
// Start feedback with delay so that current stage of import properly updates in session
setTimeout(
function() { CourseImport.startServerFeedback(feedbackUrl.replace('fillerName', file.name)) },
3000
);
} else {
bar.show();
fill.width(percentVal);
fill.html(percentVal);
}
},
done: function(e, data){
bar.hide();
window.onbeforeunload = null;
CourseImport.displayFinishedImport();
},
start: function(e) {
window.onbeforeunload = function() {
return "${_("Your import is in progress; navigating away will abort it.")}";
}
},
sequentialUploads: true,
notifyOnError: false
});
}); // end define()
</script>
<%block name="requirejs">
require(["js/factories/import"], function(ImportFactory) {
ImportFactory("${import_status_url}");
});
</%block>

View File

@@ -5,31 +5,10 @@
<%block name="title">${_("My Courses")}</%block>
<%block name="bodyclass">is-signedin index view-dashboard</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) {
// showing/hiding creation rights UI
$('.show-creationrights').click(function(e){
(e).preventDefault();
$(this).closest('.wrapper-creationrights').toggleClass('is-shown').find('.ui-toggle-control').toggleClass('current');
});
var reloadPage = function () {
location.reload();
};
var showError = function () {
$('#request-coursecreator-submit').toggleClass('has-error').find('.label').text('Sorry, there was error with your request');
$('#request-coursecreator-submit').find('.icon-cog').toggleClass('icon-spin');
};
$('#request-coursecreator').ajaxForm({error: showError, success: reloadPage});
$('#request-coursecreator-submit').click(function(e){
$(this).toggleClass('is-disabled is-submitting').find('.label').text('Submitting Your Request');
});
});
</script>
<%block name="requirejs">
require(["js/factories/index"], function (IndexFactory) {
IndexFactory();
});
</%block>
<%block name="content">

View File

@@ -57,42 +57,8 @@ from django.utils.translation import ugettext as _
</div>
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["jquery", "jquery.cookie", "utility"], function($) {
function postJSON(url, data, callback) {
$.ajax({type:'POST',
url: url,
dataType: 'json',
data: data,
success: callback,
headers : {'X-CSRFToken':$.cookie('csrftoken')}
});
}
$('form#login_form').submit(function(e) {
e.preventDefault();
var submit_data = $('#login_form').serialize();
postJSON('/login_post',
submit_data,
function(json) {
if(json.success) {
var next = /next=([^&]*)/g.exec(decodeURIComponent(window.location.search));
if (next && next.length > 1 && !isExternal(next[1])) {
location.href = next[1];
}
else location.href = "${reverse('homepage')}";
} else if($('#login_error').length == 0) {
$('#login_form').prepend('<div id="login_error" class="message message-status error">' + json.value + '</span></div>');
$('#login_error').addClass('is-shown');
} else {
$('#login_error').stop().addClass('is-shown');
$('#login_error').html(json.value);
}
}
);
<%block name="requirejs">
require(["js/factories/login"], function(LoginFactory) {
LoginFactory("${reverse('homepage')}");
});
});
</script>
</%block>

View File

@@ -161,216 +161,8 @@
</div>
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["jquery", "underscore", "gettext", "js/views/feedback_prompt"],
function($, _, gettext, PromptView) {
var staffEmails = ${json.dumps([user.email for user in staff])};
var tplUserURL = "${reverse('contentstore.views.course_team_handler', kwargs={'course_key_string': unicode(context_course.id), 'email': '@@EMAIL@@'})}"
var unknownErrorMessage = gettext("Unknown");
$(document).ready(function() {
var $createUserForm = $('#create-user-form');
var $createUserFormWrapper = $createUserForm.closest('.wrapper-create-user');
$createUserForm.bind('submit', function(e) {
e.preventDefault();
var email = $('#user-email-input').val().trim();
if(!email) {
var msg = new PromptView.Error({
title: gettext("A valid email address is required"),
message: gettext("You must enter a valid email address in order to add a new team member"),
actions: {
primary: {
text: gettext("Return and add email address"),
click: function(view) {
view.hide();
$("#user-email-input").focus();
}
}
}
})
msg.show();
return;
}
if(_.contains(staffEmails, email)) {
var msg = new PromptView.Warning({
title: gettext("Already a course team member"),
message: _.template(gettext("{email} is already on the “{course}” team. If you're trying to add a new member, please double-check the email address you provided."), {email: email, course: course.escape('name')}, {interpolate: /\{(.+?)\}/g}),
actions: {
primary: {
text: gettext("Return to team listing"),
click: function(view) {
view.hide();
$("#user-email-input").focus();
}
}
}
})
msg.show();
return;
}
var url = tplUserURL.replace("@@EMAIL@@", $('#user-email-input').val().trim())
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
role: 'staff',
}),
success: function(data) {
location.reload();
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
} catch (e) {
message = unknownErrorMessage
}
var prompt = new PromptView.Error({
title: gettext("Error adding user"),
message: message,
actions: {
primary: {
text: gettext("OK"),
click: function(view) {
view.hide();
$("#user-email-input").focus()
}
}
}
})
prompt.show();
}
});
});
var $cancelButton = $createUserForm.find('.action-cancel');
$cancelButton.bind('click', function(e) {
e.preventDefault();
$('.create-user-button').toggleClass('is-disabled');
$createUserFormWrapper.toggleClass('is-shown');
$('#user-email-input').val('');
});
$('.create-user-button').bind('click', function(e) {
e.preventDefault();
$('.create-user-button').toggleClass('is-disabled');
$createUserFormWrapper.toggleClass('is-shown');
$createUserForm.find('#user-email-input').focus();
});
$('body').bind('keyup', function(e) {
if(e.which == 27) {
$cancelButton.click();
}
});
$('.remove-user').click(function() {
var email = $(this).data('id');
var msg = new PromptView.Warning({
title: gettext("Are you sure?"),
message: _.template(gettext("Are you sure you want to delete {email} from the course team for “{course}”?"), {email: email, course: course.get('name')}, {interpolate: /\{(.+?)\}/g}),
actions: {
primary: {
text: gettext("Delete"),
click: function(view) {
view.hide();
var url = tplUserURL.replace("@@EMAIL@@", email)
$.ajax({
url: url,
type: 'DELETE',
dataType: 'json',
contentType: 'application/json',
success: function(data) {
location.reload();
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
} catch (e) {
message = unknownErrorMessage;
}
var prompt = new PromptView.Error({
title: gettext("Error removing user"),
message: message,
actions: {
primary: {
text: gettext("OK"),
click: function(view) {
view.hide();
}
}
}
})
prompt.show();
}
});
}
},
secondary: {
text: gettext("Cancel"),
click: function(view) {
view.hide();
}
}
}
});
msg.show();
});
$(".toggle-admin-role").click(function(e) {
e.preventDefault()
var type;
if($(this).hasClass("add-admin-role")) {
role = 'instructor';
} else {
role = 'staff';
}
var url = $(this).closest("li[data-url]").data("url");
$.ajax({
url: url,
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify({
role: role
}),
success: function(data) {
location.reload();
},
notifyOnError: false,
error: function(jqXHR, textStatus, errorThrown) {
var message;
try {
message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage;
} catch (e) {
message = unknownErrorMessage;
}
var prompt = new PromptView.Error({
title: gettext("There was an error changing the user's role"),
message: message,
actions: {
primary: {
text: gettext("Try Again"),
click: function(view) {
view.hide();
}
}
}
})
prompt.show();
}
})
})
<%block name="requirejs">
require(["js/factories/manage_users"], function(ManageUsersFactory) {
ManageUsersFactory(${json.dumps([user.email for user in staff])}, "${reverse('contentstore.views.course_team_handler', kwargs={'course_key_string': unicode(context_course.id), 'email': '@@EMAIL@@'})}");
});
});
</script>
</%block>

View File

@@ -99,35 +99,8 @@
</div>
</%block>
<%block name="jsextra">
<script type="text/javascript">
require(["jquery", "jquery.cookie"], function($) {
$("form :input").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() {
$("label").removeClass("is-focused");
});
$('form#register_form').submit(function(e) {
e.preventDefault();
var submit_data = $('#register_form').serialize();
$.ajax({
url: '/create_account',
type: 'POST',
dataType: 'json',
data: submit_data,
headers: {'X-CSRFToken': $.cookie('csrftoken')},
success: function(json) {
location.href = "/course/";
},
error: function(jqXHR, textStatus, errorThrown) {
json = $.parseJSON(jqXHR.responseText);
$('#register_error').html(json.value).stop().addClass('is-shown');
},
notifyOnError: false
<%block name="requirejs">
require(["js/factories/register"], function (RegisterFactory) {
RegisterFactory();
});
});
});
</script>
</%block>

View File

@@ -25,30 +25,13 @@
window.CMS = window.CMS || {};
CMS.URL = CMS.URL || {};
CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/settings/main"],
function(doc, $, CourseDetailsModel, MainView) {
// highlighting labels when fields are focused in
$("form :input").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() {
$("label").removeClass("is-focused");
});
var model = new CourseDetailsModel();
model.urlRoot = '${details_url}';
model.fetch({
success: function(model) {
var editor = new MainView({
el: $('.settings-details'),
model: model
});
editor.render();
},
reset: true
});
});
</script>
</%block>
<%block name="requirejs">
require(["js/factories/settings"], function(SettingsFactory) {
SettingsFactory("${details_url}");
});
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">

View File

@@ -9,53 +9,18 @@
<%block name="title">${_("Advanced Settings")}</%block>
<%block name="bodyclass">is-signedin course advanced view-settings</%block>
<%block name="jsextra">
<%block name="header_extras">
% for template_name in ["advanced_entry", "basic-modal", "modal-button", "validation-error-modal"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<script type="text/javascript">
require(["domReady!", "jquery", "gettext", "js/models/settings/advanced", "js/views/settings/advanced"],
function(doc, $, gettext, AdvancedSettingsModel, AdvancedSettingsView) {
$("form :input").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() {
$("label").removeClass("is-focused");
});
// proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern
var advancedModel = new AdvancedSettingsModel(${advanced_dict | n}, {parse: true});
advancedModel.url = "${advanced_settings_url}";
var editor = new AdvancedSettingsView({
el: $('.settings-advanced'),
model: advancedModel
});
editor.render();
$("#deprecated-settings").click(function() {
var $this = $(this);
var wrapperDeprecatedSetting = $('.wrapper-deprecated-setting');
var deprecatedSettingsLabel = $('.deprecated-settings-label');
if ($this.is(':checked')) {
wrapperDeprecatedSetting.addClass('is-set');
deprecatedSettingsLabel.text('${escapejs(_('Hide Deprecated Settings'))}');
editor.render_deprecated = true;
}
else {
wrapperDeprecatedSetting.removeClass('is-set');
deprecatedSettingsLabel.text('${escapejs(_('Show Deprecated Settings'))}');
editor.render_deprecated = false;
}
editor.render();
});
});
</script>
<%block name="requirejs">
require(["js/factories/settings_advanced"], function(SettingsAdvancedFactory) {
SettingsAdvancedFactory(${advanced_dict | n}, "${advanced_settings_url}");
});
</%block>
<%block name="content">

View File

@@ -19,24 +19,11 @@
<%block name="jsextra">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
<script type="text/javascript">
require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings/course_grading_policy"], function(doc, $, GradingView, CourseGradingPolicyModel) {
$("form :input").focus(function() {
$("label[for='" + this.id + "']").addClass("is-focused");
}).blur(function() {
$("label").removeClass("is-focused");
</%block>
<%block name="requirejs">
require(["js/factories/settings_graders"], function(SettingsGradersFactory) {
SettingsGradersFactory(${course_details|n}, "${grading_url}");
});
var model = new CourseGradingPolicyModel(${course_details|n},{parse:true});
model.urlRoot = '${grading_url}';
var editor = new GradingView({
el: $('.settings-grading'),
model : model
});
editor.render();
});
</script>
</%block>
<%block name="content">

View File

@@ -22,26 +22,13 @@ CMS.URL = CMS.URL || {};
CMS.URL.UPLOAD_ASSET = "${upload_asset_url}"
CMS.URL.TEXTBOOKS = "${textbook_url}"
CMS.URL.LMS_BASE = "${settings.LMS_BASE}"
require(["js/models/section", "js/collections/textbook", "js/views/list_textbooks"],
function(Section, TextbookCollection, ListTextbooksView) {
var textbooks = new TextbookCollection(${json.dumps(textbooks)}, {parse: true});
var tbView = new ListTextbooksView({collection: textbooks});
$(function() {
$(".content-primary").append(tbView.render().el);
$(".nav-actions .new-button").click(function(e) {
tbView.addOne(e);
})
$(window).on("beforeunload", function() {
var dirty = textbooks.find(function(textbook) { return textbook.isDirty(); });
if(dirty) {
return "${_('You have unsaved changes. Do you really want to leave this page?')}";
}
})
})
});
</script>
</%block>
<%block name="requirejs">
require(["js/factories/textbooks"], function(TextbooksFactory) {
TextbooksFactory(${json.dumps(textbooks)});
});
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">

View File

@@ -163,6 +163,90 @@ class CourseMode(models.Model):
return mode.min_price
return 0
@classmethod
def has_payment_options(cls, course_id):
"""Determines if there is any mode that has payment options
Check the dict of course modes and see if any of them have a minimum price or
suggested prices. Returns True if any course mode has a payment option.
Args:
course_mode_dict (dict): Dictionary mapping course mode slugs to Modes
Returns:
True if any course mode has a payment option.
"""
for mode in cls.modes_for_course(course_id):
if mode.min_price > 0 or mode.suggested_prices != '':
return True
return False
@classmethod
def can_auto_enroll(cls, course_id, modes_dict=None):
"""Check whether students should be auto-enrolled in the course.
If a course is behind a paywall (e.g. professional ed or white-label),
then users should NOT be auto-enrolled. Instead, the user will
be enrolled when he/she completes the payment flow.
Otherwise, users can be enrolled in the default mode "honor"
with the option to upgrade later.
Args:
course_id (CourseKey): The course to check.
Keyword Args:
modes_dict (dict): If provided, use these course modes.
Useful for avoiding unnecessary database queries.
Returns:
bool
"""
if modes_dict is None:
modes_dict = cls.modes_for_course_dict(course_id)
# Professional mode courses are always behind a paywall
if "professional" in modes_dict:
return False
# White-label uses course mode honor with a price
# to indicate that the course is behind a paywall.
if cls.is_white_label(course_id, modes_dict=modes_dict):
return False
# Check that the default mode is available.
return ("honor" in modes_dict)
@classmethod
def is_white_label(cls, course_id, modes_dict=None):
"""Check whether a course is a "white label" (paid) course.
By convention, white label courses have a course mode slug "honor"
and a price.
Args:
course_id (CourseKey): The course to check.
Keyword Args:
modes_dict (dict): If provided, use these course modes.
Useful for avoiding unnecessary database queries.
Returns:
bool
"""
if modes_dict is None:
modes_dict = cls.modes_for_course_dict(course_id)
# White-label uses course mode honor with a price
# to indicate that the course is behind a paywall.
if "honor" in modes_dict and len(modes_dict) == 1:
if modes_dict["honor"].min_price > 0 or modes_dict["honor"].suggested_prices != '':
return True
return False
@classmethod
def min_course_price_for_currency(cls, course_id, currency):
"""

View File

@@ -7,12 +7,14 @@ Replace this with more appropriate tests for your application.
from datetime import datetime, timedelta
import pytz
import ddt
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django.test import TestCase
from course_modes.models import CourseMode, Mode
@ddt.ddt
class CourseModeModelTest(TestCase):
"""
Tests for the CourseMode model
@@ -127,3 +129,37 @@ class CourseModeModelTest(TestCase):
mode = CourseMode.verified_mode_for_course(self.course_key)
self.assertEqual(mode.slug, 'professional')
def test_course_has_payment_options(self):
# Has no payment options.
honor, _ = self.create_mode('honor', 'Honor')
self.assertFalse(CourseMode.has_payment_options(self.course_key))
# Now we do have a payment option.
verified, _ = self.create_mode('verified', 'Verified', min_price=5)
self.assertTrue(CourseMode.has_payment_options(self.course_key))
# Unset verified's minimum price.
verified.min_price = 0
verified.save()
self.assertFalse(CourseMode.has_payment_options(self.course_key))
# Finally, give the honor mode payment options
honor.suggested_prices = '5, 10, 15'
honor.save()
self.assertTrue(CourseMode.has_payment_options(self.course_key))
@ddt.data(
([], True),
([("honor", 0), ("audit", 0), ("verified", 100)], True),
([("honor", 100)], False),
([("professional", 100)], False),
)
@ddt.unpack
def test_can_auto_enroll(self, modes_and_prices, can_auto_enroll):
# Create the modes and min prices
for mode_slug, min_price in modes_and_prices:
self.create_mode(mode_slug, mode_slug.capitalize(), min_price=min_price)
# Verify that we can or cannot auto enroll
self.assertEqual(CourseMode.can_auto_enroll(self.course_key), can_auto_enroll)

View File

@@ -32,41 +32,33 @@ class CourseModeViewTest(ModuleStoreTestCase):
self.client.login(username=self.user.username, password="edx")
@ddt.data(
# is_active?, enrollment_mode, upgrade?, redirect?
(True, 'verified', True, False), # User has an active verified enrollment and is trying to upgrade
(True, 'verified', False, True), # User has an active verified enrollment and is not trying to upgrade
(True, 'honor', True, False), # User has an active honor enrollment and is trying to upgrade
(True, 'honor', False, False), # User has an active honor enrollment and is not trying to upgrade
(True, 'audit', True, False), # User has an active audit enrollment and is trying to upgrade
(True, 'audit', False, False), # User has an active audit enrollment and is not trying to upgrade
(False, 'verified', True, True), # User has an inactive verified enrollment and is trying to upgrade
(False, 'verified', False, True), # User has an inactive verified enrollment and is not trying to upgrade
(False, 'honor', True, True), # User has an inactive honor enrollment and is trying to upgrade
(False, 'honor', False, True), # User has an inactive honor enrollment and is not trying to upgrade
(False, 'audit', True, True), # User has an inactive audit enrollment and is trying to upgrade
(False, 'audit', False, True), # User has an inactive audit enrollment and is not trying to upgrade
# is_active?, enrollment_mode, redirect?
(True, 'verified', True),
(True, 'honor', False),
(True, 'audit', False),
(False, 'verified', False),
(False, 'honor', False),
(False, 'audit', False),
(False, None, False),
)
@ddt.unpack
def test_redirect_to_dashboard(self, is_active, enrollment_mode, upgrade, redirect):
def test_redirect_to_dashboard(self, is_active, enrollment_mode, redirect):
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
CourseModeFactory(mode_slug=mode, course_id=self.course.id)
# Enroll the user in the test course
CourseEnrollmentFactory(
is_active=is_active,
mode=enrollment_mode,
course_id=self.course.id,
user=self.user
)
if enrollment_mode is not None:
CourseEnrollmentFactory(
is_active=is_active,
mode=enrollment_mode,
course_id=self.course.id,
user=self.user
)
# Configure whether we're upgrading or not
get_params = {}
if upgrade:
get_params = {'upgrade': True}
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url, get_params)
response = self.client.get(url)
# Check whether we were correctly redirected
if redirect:
@@ -74,7 +66,19 @@ class CourseModeViewTest(ModuleStoreTestCase):
else:
self.assertEquals(response.status_code, 200)
def test_redirect_to_dashboard_no_enrollment(self):
def test_upgrade_copy(self):
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
CourseModeFactory(mode_slug=mode, course_id=self.course.id)
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url, {"upgrade": True})
# Verify that the upgrade copy is displayed instead
# of the usual text.
self.assertContains(response, "Upgrade Your Enrollment")
def test_no_enrollment(self):
# Create the course modes
for mode in ('audit', 'honor', 'verified'):
CourseModeFactory(mode_slug=mode, course_id=self.course.id)
@@ -83,7 +87,7 @@ class CourseModeViewTest(ModuleStoreTestCase):
url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url)
self.assertRedirects(response, reverse('dashboard'))
self.assertEquals(response.status_code, 200)
@ddt.data(
'',
@@ -121,7 +125,7 @@ class CourseModeViewTest(ModuleStoreTestCase):
# TODO: Fix it so that response.templates works w/ mako templates, and then assert
# that the right template rendered
def test_professional_registration(self):
def test_professional_enrollment(self):
# The only course mode is professional ed
CourseModeFactory(mode_slug='professional', course_id=self.course.id)

View File

@@ -17,6 +17,7 @@ from course_modes.models import CourseMode
from courseware.access import has_access
from student.models import CourseEnrollment
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.keys import CourseKey
from util.db import commit_on_success_with_read_committed
from xmodule.modulestore.django import modulestore
@@ -26,10 +27,10 @@ class ChooseModeView(View):
When a get request is used, shows the selection page.
When a post request is used, assumes that it is a form submission
When a post request is used, assumes that it is a form submission
from the selection page, parses the response, and then sends user
to the next step in the flow.
"""
@method_decorator(login_required)
@@ -48,28 +49,19 @@ class ChooseModeView(View):
Response
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(request.user, course_key)
course_key = CourseKey.from_string(course_id)
upgrade = request.GET.get('upgrade', False)
request.session['attempting_upgrade'] = upgrade
# Students will already have an active course enrollment at this stage,
# but we should still show them the "choose your track" page so they have
# the option to enter the verification/payment flow.
go_to_dashboard = (
not upgrade and enrollment_mode in ['verified', 'professional']
)
if go_to_dashboard:
return redirect(reverse('dashboard'))
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(request.user, course_key)
modes = CourseMode.modes_for_course_dict(course_key)
# We assume that, if 'professional' is one of the modes, it is the *only* mode.
# If we offer more modes alongside 'professional' in the future, this will need to route
# to the usual "choose your track" page.
if "professional" in modes:
has_enrolled_professional = (enrollment_mode == "professional" and is_active)
if "professional" in modes and not has_enrolled_professional:
return redirect(
reverse(
'verify_student_show_requirements',
@@ -77,14 +69,15 @@ class ChooseModeView(View):
)
)
# If a user's course enrollment is inactive at this stage, the track
# selection page may have been visited directly, so we should redirect
# the user to their dashboard. By the time the user gets here during the
# normal registration process, they will already have an activated enrollment;
# the button appearing on the track selection page only redirects the user to
# the dashboard, and we don't want the user to be confused when they click the
# honor button and are taken to their dashboard without being enrolled.
if not is_active:
# If there isn't a verified mode available, then there's nothing
# to do on this page. The user has almost certainly been auto-registered
# in the "honor" track by this point, so we send the user
# to the dashboard.
if not CourseMode.has_verified_mode(modes):
return redirect(reverse('dashboard'))
# If a user has already paid, redirect them to the dashboard.
if is_active and enrollment_mode in CourseMode.VERIFIED_MODES:
return redirect(reverse('dashboard'))
donation_for_course = request.session.get("donation_for_course", {})

View File

@@ -0,0 +1,113 @@
"""Helpers for the student app. """
import time
from django.utils.http import cookie_date
from django.conf import settings
from django.core.urlresolvers import reverse
from opaque_keys.edx.keys import CourseKey
from course_modes.models import CourseMode
from third_party_auth import ( # pylint: disable=W0611
pipeline, provider,
is_enabled as third_party_auth_enabled
)
def auth_pipeline_urls(auth_entry, redirect_url=None, course_id=None):
"""Retrieve URLs for each enabled third-party auth provider.
These URLs are used on the "sign up" and "sign in" buttons
on the login/registration forms to allow users to begin
authentication with a third-party provider.
Optionally, we can redirect the user to an arbitrary
url after auth completes successfully. We use this
to redirect the user to a page that required login,
or to send users to the payment flow when enrolling
in a course.
Args:
auth_entry (string): Either `pipeline.AUTH_ENTRY_LOGIN` or `pipeline.AUTH_ENTRY_REGISTER`
Keyword Args:
redirect_url (unicode): If provided, send users to this URL
after they successfully authenticate.
course_id (unicode): The ID of the course the user is enrolling in.
We use this to send users to the track selection page
if the course has a payment option.
Note that `redirect_url` takes precedence over the redirect
to the track selection page.
Returns:
dict mapping provider names to URLs
"""
if not third_party_auth_enabled():
return {}
if redirect_url is not None:
pipeline_redirect = redirect_url
elif course_id is not None:
# If the course is white-label (paid), then we send users
# to the shopping cart. (There is a third party auth pipeline
# step that will add the course to the cart.)
if CourseMode.is_white_label(CourseKey.from_string(course_id)):
pipeline_redirect = reverse("shoppingcart.views.show_cart")
# Otherwise, send the user to the track selection page.
# The track selection page may redirect the user to the dashboard
# (if the only available mode is honor), or directly to verification
# (for professional ed).
else:
pipeline_redirect = reverse(
"course_modes_choose",
kwargs={'course_id': unicode(course_id)}
)
else:
pipeline_redirect = None
return {
provider.NAME: pipeline.get_login_url(
provider.NAME, auth_entry,
enroll_course_id=course_id,
redirect_url=pipeline_redirect
)
for provider in provider.Registry.enabled()
}
def set_logged_in_cookie(request, response):
"""Set a cookie indicating that the user is logged in.
Some installations have an external marketing site configured
that displays a different UI when the user is logged in
(e.g. a link to the student dashboard instead of to the login page)
Arguments:
request (HttpRequest): The request to the view, used to calculate
the cookie's expiration date based on the session expiration date.
response (HttpResponse): The response on which the cookie will be set.
Returns:
HttpResponse
"""
if request.session.get_expire_at_browser_close():
max_age = None
expires = None
else:
max_age = request.session.get_expiry_age()
expires_time = time.time() + max_age
expires = cookie_date(expires_time)
response.set_cookie(
settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age,
expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
path='/', secure=None, httponly=None,
)
return response
def is_logged_in_cookie_set(request):
"""Check whether the request has the logged in cookie set. """
return settings.EDXMKTG_COOKIE_NAME in request.COOKIES

View File

@@ -1,81 +1,135 @@
"""
Transfer Student Management Command
"""
from django.db import transaction
from opaque_keys.edx.keys import CourseKey
from optparse import make_option
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
from student.models import CourseEnrollment
from shoppingcart.models import CertificateItem
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from track.management.tracked_command import TrackedCommand
class Command(BaseCommand):
class TransferStudentError(Exception):
"""Generic Error when handling student transfers."""
pass
class Command(TrackedCommand):
"""Management Command for transferring students from one course to new courses."""
help = """
This command takes two course ids as input and transfers
all students enrolled in one course into the other. This will
remove them from the first class and enroll them in the second
class in the same mode as the first one. eg. honor, verified,
remove them from the first class and enroll them in the specified
class(es) in the same mode as the first one. eg. honor, verified,
audit.
example:
# Transfer students from the old demoX class to a new one.
manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course -t edX/Open_DemoX/new_demoX
# Transfer students from old course to new, with original certificate items.
manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course -t edX/Open_DemoX/new_demoX -c true
# Transfer students from the old demoX class into two new classes.
manage.py ... transfer_students -f edX/Open_DemoX/edx_demo_course
-t edX/Open_DemoX/new_demoX,edX/Open_DemoX/edX_Insider
"""
option_list = BaseCommand.option_list + (
option_list = TrackedCommand.option_list + (
make_option('-f', '--from',
metavar='SOURCE_COURSE',
dest='source_course',
help='The course to transfer students from.'),
make_option('-t', '--to',
metavar='DEST_COURSE',
dest='dest_course',
help='The new course to enroll the student into.'),
metavar='DEST_COURSE_LIST',
dest='dest_course_list',
help='The new course(es) to enroll the student into.'),
make_option('-c', '--transfer-certificates',
metavar='TRANSFER_CERTIFICATES',
dest='transfer_certificates',
help="If True, try to transfer certificate items to the new course.")
)
def handle(self, *args, **options):
source_key = SlashSeparatedCourseKey.from_deprecated_string(options['source_course'])
dest_key = SlashSeparatedCourseKey.from_deprecated_string(options['dest_course'])
@transaction.commit_manually
def handle(self, *args, **options): # pylint: disable=unused-argument
source_key = CourseKey.from_string(options.get('source_course', ''))
dest_keys = []
for course_key in options.get('dest_course_list', '').split(','):
dest_keys.append(CourseKey.from_string(course_key))
if not source_key or not dest_keys:
raise TransferStudentError(u"Must have a source course and destination course specified.")
tc_option = options.get('transfer_certificates', '')
transfer_certificates = ('true' == tc_option.lower()) if tc_option else False
if transfer_certificates and len(dest_keys) != 1:
raise TransferStudentError(u"Cannot transfer certificate items from one course to many.")
source_students = User.objects.filter(
courseenrollment__course_id=source_key
)
for user in source_students:
if CourseEnrollment.is_enrolled(user, dest_key):
# Un Enroll from source course but don't mess
# with the enrollment in the destination course.
CourseEnrollment.unenroll(user, source_key)
print("Unenrolled {} from {}".format(user.username, source_key.to_deprecated_string()))
msg = "Skipping {}, already enrolled in destination course {}"
print(msg.format(user.username, dest_key.to_deprecated_string()))
continue
with transaction.commit_on_success():
print("Moving {}.".format(user.username))
# Find the old enrollment.
enrollment = CourseEnrollment.objects.get(
user=user,
course_id=source_key
)
print("Moving {}.".format(user.username))
# Find the old enrollment.
enrollment = CourseEnrollment.objects.get(
user=user,
course_id=source_key
# Move the Student between the classes.
mode = enrollment.mode
old_is_active = enrollment.is_active
CourseEnrollment.unenroll(user, source_key, emit_unenrollment_event=False)
print(u"Unenrolled {} from {}".format(user.username, unicode(source_key)))
for dest_key in dest_keys:
if CourseEnrollment.is_enrolled(user, dest_key):
# Un Enroll from source course but don't mess
# with the enrollment in the destination course.
msg = u"Skipping {}, already enrolled in destination course {}"
print(msg.format(user.username, unicode(dest_key)))
else:
new_enrollment = CourseEnrollment.enroll(user, dest_key, mode=mode)
# Un-enroll from the new course if the user had un-enrolled
# form the old course.
if not old_is_active:
new_enrollment.update_enrollment(is_active=False, emit_unenrollment_event=False)
if transfer_certificates:
self._transfer_certificate_item(source_key, enrollment, user, dest_keys, new_enrollment)
@staticmethod
def _transfer_certificate_item(source_key, enrollment, user, dest_keys, new_enrollment):
""" Transfer the certificate item from one course to another.
Do not use this generally, since certificate items are directly associated with a particular purchase.
This should only be used when a single course to a new location. This cannot be used when transferring
from one course to many.
Args:
source_key (str): The course key string representation for the original course.
enrollment (CourseEnrollment): The original enrollment to move the certificate item from.
user (User): The user to transfer the item for.
dest_keys (list): A list of course key strings to transfer the item to.
new_enrollment (CourseEnrollment): The new enrollment to associate the certificate item with.
Returns:
None
"""
try:
certificate_item = CertificateItem.objects.get(
course_id=source_key,
course_enrollment=enrollment
)
except CertificateItem.DoesNotExist:
print(u"No certificate for {}".format(user))
return
# Move the Student between the classes.
mode = enrollment.mode
old_is_active = enrollment.is_active
CourseEnrollment.unenroll(user, source_key)
new_enrollment = CourseEnrollment.enroll(user, dest_key, mode=mode)
# Unenroll from the new coures if the user had unenrolled
# form the old course.
if not old_is_active:
new_enrollment.update_enrollment(is_active=False)
if mode == 'verified':
try:
certificate_item = CertificateItem.objects.get(
course_id=source_key,
course_enrollment=enrollment
)
except CertificateItem.DoesNotExist:
print("No certificate for {}".format(user))
continue
certificate_item.course_id = dest_key
certificate_item.course_enrollment = new_enrollment
certificate_item.save()
certificate_item.course_id = dest_keys[0]
certificate_item.course_enrollment = new_enrollment

View File

@@ -0,0 +1 @@
"""Tests for Student Management Commands."""

View File

@@ -0,0 +1,60 @@
"""
Tests the transfer student management command
"""
from django.conf import settings
from opaque_keys.edx import locator
import unittest
import ddt
from student.management.commands import transfer_students
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
class TestTransferStudents(ModuleStoreTestCase):
"""Tests for transferring students between courses."""
PASSWORD = 'test'
def test_transfer_students(self):
student = UserFactory()
student.set_password(self.PASSWORD) # pylint: disable=E1101
student.save() # pylint: disable=E1101
# Original Course
original_course_location = locator.CourseLocator('Org0', 'Course0', 'Run0')
course = self._create_course(original_course_location)
# Enroll the student in 'verified'
CourseEnrollment.enroll(student, course.id, mode="verified")
# New Course 1
course_location_one = locator.CourseLocator('Org1', 'Course1', 'Run1')
new_course_one = self._create_course(course_location_one)
# New Course 2
course_location_two = locator.CourseLocator('Org2', 'Course2', 'Run2')
new_course_two = self._create_course(course_location_two)
original_key = unicode(course.id)
new_key_one = unicode(new_course_one.id)
new_key_two = unicode(new_course_two.id)
# Run the actual management command
transfer_students.Command().handle(
source_course=original_key, dest_course_list=new_key_one + "," + new_key_two
)
# Confirm the enrollment mode is verified on the new courses, and enrollment is enabled as appropriate.
self.assertEquals(('verified', False), CourseEnrollment.enrollment_mode_for_user(student, course.id))
self.assertEquals(('verified', True), CourseEnrollment.enrollment_mode_for_user(student, new_course_one.id))
self.assertEquals(('verified', True), CourseEnrollment.enrollment_mode_for_user(student, new_course_two.id))
def _create_course(self, course_location):
""" Creates a course """
return CourseFactory.create(
org=course_location.org,
number=course_location.course,
run=course_location.run
)

View File

@@ -776,7 +776,7 @@ class CourseEnrollment(models.Model):
is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed
return is_course_full
def update_enrollment(self, mode=None, is_active=None):
def update_enrollment(self, mode=None, is_active=None, emit_unenrollment_event=True):
"""
Updates an enrollment for a user in a class. This includes options
like changing the mode, toggling is_active True/False, etc.
@@ -784,6 +784,7 @@ class CourseEnrollment(models.Model):
Also emits relevant events for analytics purposes.
This saves immediately.
"""
activation_changed = False
# if is_active is None, then the call to update_enrollment didn't specify
@@ -813,7 +814,7 @@ class CourseEnrollment(models.Model):
u"mode:{}".format(self.mode)]
)
else:
elif emit_unenrollment_event:
UNENROLL_DONE.send(sender=None, course_enrollment=self)
self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
@@ -987,7 +988,7 @@ class CourseEnrollment(models.Model):
raise
@classmethod
def unenroll(cls, user, course_id):
def unenroll(cls, user, course_id, emit_unenrollment_event=True):
"""
Remove the user from a given course. If the relevant `CourseEnrollment`
object doesn't exist, we log an error but don't throw an exception.
@@ -997,10 +998,12 @@ class CourseEnrollment(models.Model):
adding an enrollment for it.
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
`emit_unenrollment_events` can be set to False to suppress events firing.
"""
try:
record = CourseEnrollment.objects.get(user=user, course_id=course_id)
record.update_enrollment(is_active=False)
record.update_enrollment(is_active=False, emit_unenrollment_event=emit_unenrollment_event)
except cls.DoesNotExist:
err_msg = u"Tried to unenroll student {} from {} but they were not enrolled"

View File

@@ -11,12 +11,8 @@ from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
from social.strategies.django_strategy import DjangoStrategy
from django.test.client import RequestFactory
from student.tests.factories import UserFactory, CourseModeFactory
from student.models import CourseEnrollment
from student.views import register_user
from third_party_auth.pipeline import change_enrollment as change_enrollment_third_party
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
@@ -59,7 +55,7 @@ class EnrollmentTest(ModuleStoreTestCase):
# Professional ed
# Expect that we're sent to the "choose your track" page
# (which will, in turn, redirect us to a page where we can verify/pay)
# We should NOT be auto-enrolled, because that would be giving
# We should NOT be auto-enrolled, because that would be giving
# away an expensive course for free :)
(['professional'], 'course_modes_choose', None),
)
@@ -97,25 +93,6 @@ class EnrollmentTest(ModuleStoreTestCase):
self.assertTrue(is_active)
self.assertEqual(course_mode, enrollment_mode)
def test_enroll_from_third_party_redirect(self):
"""
Test that, when a user visits the registration page *after* visiting a course,
if they go on to register and/or log in via third-party auth, they'll be enrolled
in that course.
The testing here is a bit hackish, since we just ping the registration page, then
directly call the step in the third party pipeline that registers the user if
`registration_course_id` is set in the session, but it should catch any major breaks.
"""
self.client.logout()
self.client.get(reverse('register_user'), {'course_id': self.course.id})
self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.dummy_request = RequestFactory().request()
self.dummy_request.session = self.client.session
strategy = DjangoStrategy(RequestFactory, request=self.dummy_request)
change_enrollment_third_party(is_register=True, strategy=strategy, user=self.user)
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
def test_unenroll(self):
# Enroll the student in the course
CourseEnrollment.enroll(self.user, self.course.id, mode="honor")

View File

@@ -0,0 +1,185 @@
"""Tests for the login and registration form rendering. """
import urllib
import unittest
from mock import patch
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
import ddt
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import CourseModeFactory
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
# This relies on third party auth being enabled and configured
# in the test settings. See the setting `THIRD_PARTY_AUTH`
# and the feature flag `ENABLE_THIRD_PARTY_AUTH`
THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"]
THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"]
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
def _third_party_login_url(backend_name, auth_entry, course_id=None, redirect_url=None):
"""Construct the login URL to start third party authentication. """
params = [("auth_entry", auth_entry)]
if redirect_url:
params.append(("next", redirect_url))
if course_id:
params.append(("enroll_course_id", course_id))
return u"{url}?{params}".format(
url=reverse("social:begin", kwargs={"backend": backend_name}),
params=urllib.urlencode(params)
)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class LoginFormTest(ModuleStoreTestCase):
"""Test rendering of the login form. """
def setUp(self):
self.url = reverse("signin_user")
self.course = CourseFactory.create()
self.course_id = unicode(self.course.id)
self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id})
self.courseware_url = reverse("courseware", args=[self.course_id])
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(THIRD_PARTY_AUTH_PROVIDERS)
def test_third_party_auth_disabled(self, provider_name):
response = self.client.get(self.url)
self.assertNotContains(response, provider_name)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_no_course_id(self, backend_name):
response = self.client.get(self.url)
expected_url = _third_party_login_url(backend_name, "login")
self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_course_id(self, backend_name):
# Provide a course ID to the login page, simulating what happens
# when a user tries to enroll in a course without being logged in
response = self.client.get(self.url, {"course_id": self.course_id})
# Expect that the course ID is added to the third party auth entry
# point, so that the pipeline will enroll the student and
# redirect the student to the track selection page.
expected_url = _third_party_login_url(
backend_name,
"login",
course_id=self.course_id,
redirect_url=self.course_modes_url
)
self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_white_label_course(self, backend_name):
# Set the course mode to honor with a min price,
# indicating that the course is behind a paywall.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Expect that we're redirected to the shopping cart
# instead of to the track selection page.
response = self.client.get(self.url, {"course_id": self.course_id})
expected_url = _third_party_login_url(
backend_name,
"login",
course_id=self.course_id,
redirect_url=reverse("shoppingcart.views.show_cart")
)
self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_redirect_url(self, backend_name):
# Try to access courseware while logged out, expecting to be
# redirected to the login page.
response = self.client.get(self.courseware_url, follow=True)
self.assertRedirects(
response,
u"{url}?next={redirect_url}".format(
url=reverse("accounts_login"),
redirect_url=self.courseware_url
)
)
# Verify that the third party auth URLs include the redirect URL
# The third party auth pipeline will redirect to this page
# once the user successfully authenticates.
expected_url = _third_party_login_url(
backend_name,
"login",
redirect_url=self.courseware_url
)
self.assertContains(response, expected_url)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegisterFormTest(TestCase):
"""Test rendering of the registration form. """
def setUp(self):
self.url = reverse("register_user")
self.course = CourseFactory.create()
self.course_id = unicode(self.course.id)
self.course_modes_url = reverse("course_modes_choose", kwargs={"course_id": self.course_id})
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(*THIRD_PARTY_AUTH_PROVIDERS)
def test_third_party_auth_disabled(self, provider_name):
response = self.client.get(self.url)
self.assertNotContains(response, provider_name)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_register_third_party_auth_no_course_id(self, backend_name):
response = self.client.get(self.url)
expected_url = _third_party_login_url(backend_name, "register")
self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_register_third_party_auth_with_course_id(self, backend_name):
response = self.client.get(self.url, {"course_id": self.course_id})
expected_url = _third_party_login_url(
backend_name,
"register",
course_id=self.course_id,
redirect_url=self.course_modes_url
)
self.assertContains(response, expected_url)
@ddt.data(*THIRD_PARTY_AUTH_BACKENDS)
def test_third_party_auth_with_white_label_course(self, backend_name):
# Set the course mode to honor with a min price,
# indicating that the course is behind a paywall.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Expect that we're redirected to the shopping cart
# instead of to the track selection page.
response = self.client.get(self.url, {"course_id": self.course_id})
expected_url = _third_party_login_url(
backend_name,
"register",
course_id=self.course_id,
redirect_url=reverse("shoppingcart.views.show_cart")
)
self.assertContains(response, expected_url)

View File

@@ -94,6 +94,7 @@ from util.password_policy_validators import (
import third_party_auth
from third_party_auth import pipeline, provider
from student.helpers import auth_pipeline_urls, set_logged_in_cookie
from xmodule.error_module import ErrorDescriptor
from shoppingcart.models import CourseRegistrationCode
@@ -353,13 +354,15 @@ def signin_user(request):
if request.user.is_authenticated():
return redirect(reverse('dashboard'))
course_id = request.GET.get('course_id')
context = {
'course_id': request.GET.get('course_id'),
'course_id': course_id,
'enrollment_action': request.GET.get('enrollment_action'),
# Bool injected into JS to submit form if we're inside a running third-
# party auth pipeline; distinct from the actual instance of the running
# pipeline, if any.
'pipeline_running': 'true' if pipeline.running(request) else 'false',
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, course_id=course_id),
'platform_name': microsite.get_value(
'platform_name',
settings.PLATFORM_NAME
@@ -381,12 +384,15 @@ def register_user(request, extra_context=None):
# and registration is disabled.
return external_auth.views.redirect_with_get('root', request.GET)
course_id = request.GET.get('course_id')
context = {
'course_id': request.GET.get('course_id'),
'course_id': course_id,
'email': '',
'enrollment_action': request.GET.get('enrollment_action'),
'name': '',
'running_pipeline': None,
'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, course_id=course_id),
'platform_name': microsite.get_value(
'platform_name',
settings.PLATFORM_NAME
@@ -395,10 +401,6 @@ def register_user(request, extra_context=None):
'username': '',
}
# We save this so, later on, we can determine what course motivated a user's signup
# if they actually complete the registration process
request.session['registration_course_id'] = context['course_id']
if extra_context is not None:
context.update(extra_context)
@@ -698,7 +700,10 @@ def _allow_donation(course_modes, course_id):
True if the course is allowing donations.
"""
return DonationConfiguration.current().enabled and not CourseMode.has_verified_mode(course_modes[course_id])
donations_enabled = DonationConfiguration.current().enabled
is_verified_mode = CourseMode.has_verified_mode(course_modes[course_id])
has_payment_option = CourseMode.has_payment_options(course_id)
return donations_enabled and not is_verified_mode and not has_payment_option
def try_change_enrollment(request):
@@ -796,14 +801,9 @@ def change_enrollment(request, check_access=True):
available_modes = CourseMode.modes_for_course_dict(course_id)
# Handle professional ed as a special case.
# If professional ed is included in the list of available modes,
# then do NOT automatically enroll the student (we want them to pay first!)
# By convention, professional ed should be the *only* available course mode,
# if it's included at all -- anything else is a misconfiguration. But if someone
# messes up and adds an additional course mode, we err on the side of NOT
# accidentally giving away free courses.
if "professional" not in available_modes:
# Check that auto enrollment is allowed for this course
# (= the course is NOT behind a paywall)
if CourseMode.can_auto_enroll(course_id):
# Enroll the user using the default mode (honor)
# We're assuming that users of the course enrollment table
# will NOT try to look up the course enrollment model
@@ -819,7 +819,7 @@ def change_enrollment(request, check_access=True):
# then send the user to the choose your track page.
# (In the case of professional ed, this will redirect to a page that
# funnels users directly into the verification / payment flow)
if len(available_modes) > 1 or "professional" in available_modes:
if CourseMode.has_verified_mode(available_modes):
return HttpResponse(
reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)})
)
@@ -900,6 +900,7 @@ def accounts_login(request):
context = {
'pipeline_running': 'false',
'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to),
'platform_name': settings.PLATFORM_NAME,
}
return render_to_response('login.html', context)
@@ -1051,14 +1052,12 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
'username': username,
})
# If the user entered the flow via a specific course page, we track that
registration_course_id = request.session.get('registration_course_id')
analytics.track(
user.id,
"edx.bi.user.account.authenticated",
{
'category': "conversion",
'label': registration_course_id,
'label': request.POST.get('course_id'),
'provider': None
},
context={
@@ -1067,7 +1066,6 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
}
}
)
request.session['registration_course_id'] = None
if user is not None and user.is_active:
try:
@@ -1095,25 +1093,9 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
"redirect_url": redirect_url,
})
# set the login cookie for the edx marketing site
# we want this cookie to be accessed via javascript
# so httponly is set to None
if request.session.get_expire_at_browser_close():
max_age = None
expires = None
else:
max_age = request.session.get_expiry_age()
expires_time = time.time() + max_age
expires = cookie_date(expires_time)
response.set_cookie(
settings.EDXMKTG_COOKIE_NAME, 'true', max_age=max_age,
expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
path='/', secure=None, httponly=None,
)
return response
# Ensure that the external marketing site can
# detect that the user is logged in.
return set_logged_in_cookie(request, response)
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
AUDIT_LOG.warning(u"Login failed - Account not active for user.id: {0}, resending activation".format(user.id))
@@ -1128,6 +1110,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
}) # TODO: this should be status code 400 # pylint: disable=fixme
@ensure_csrf_cookie
def logout_user(request):
"""
@@ -1534,13 +1517,12 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
provider_name = current_provider.NAME
registration_course_id = request.session.get('registration_course_id')
analytics.track(
user.id,
"edx.bi.user.account.registered",
{
'category': 'conversion',
'label': registration_course_id,
'label': request.POST.get('course_id'),
'provider': provider_name
},
context={
@@ -1549,7 +1531,6 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
}
}
)
request.session['registration_course_id'] = None
create_comments_service_user(user)

View File

@@ -6,7 +6,7 @@ from microsite_configuration import microsite
def is_enabled():
"""Check whether third party authentication has been enabled. """
# We do this imports internally to avoid initializing settings prematurely
# We do this import internally to avoid initializing settings prematurely
from django.conf import settings
return microsite.get_value(

View File

@@ -59,6 +59,8 @@ See http://psa.matiasaguirre.net/docs/pipeline.html for more docs.
import random
import string # pylint: disable-msg=deprecated-module
from collections import OrderedDict
import urllib
import analytics
from eventtracking import tracker
@@ -69,15 +71,40 @@ from social.apps.django_app.default import models
from social.exceptions import AuthException
from social.pipeline import partial
import student
from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=F0401
from shoppingcart.exceptions import ( # pylint: disable=F0401
CourseDoesNotExistException,
ItemAlreadyInCartException,
AlreadyEnrolledInCourseException
)
from student.models import CourseEnrollment, CourseEnrollmentException
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_modes.models import CourseMode
from opaque_keys.edx.keys import CourseKey
from logging import getLogger
from . import provider
# These are the query string params you can pass
# to the URL that starts the authentication process.
#
# `AUTH_ENTRY_KEY` is required and indicates how the user
# enters the authentication process.
#
# `AUTH_REDIRECT_KEY` provides an optional URL to redirect
# to upon successful authentication
# (if not provided, defaults to `_SOCIAL_AUTH_LOGIN_REDIRECT_URL`)
#
# `AUTH_ENROLL_COURSE_ID_KEY` provides the course ID that a student
# is trying to enroll in, used to generate analytics events
# and auto-enroll students.
AUTH_ENTRY_KEY = 'auth_entry'
AUTH_REDIRECT_KEY = 'next'
AUTH_ENROLL_COURSE_ID_KEY = 'enroll_course_id'
AUTH_ENTRY_DASHBOARD = 'dashboard'
AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_PROFILE = 'profile'
@@ -192,15 +219,25 @@ def _get_enabled_provider_by_name(provider_name):
return enabled_provider
def _get_url(view_name, backend_name, auth_entry=None):
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None, enroll_course_id=None):
"""Creates a URL to hook into social auth endpoints."""
kwargs = {'backend': backend_name}
url = reverse(view_name, kwargs=kwargs)
query_params = OrderedDict()
if auth_entry:
url += '?%s=%s' % (AUTH_ENTRY_KEY, auth_entry)
query_params[AUTH_ENTRY_KEY] = auth_entry
return url
if redirect_url:
query_params[AUTH_REDIRECT_KEY] = redirect_url
if enroll_course_id:
query_params[AUTH_ENROLL_COURSE_ID_KEY] = enroll_course_id
return u"{url}?{params}".format(
url=url,
params=urllib.urlencode(query_params)
)
def get_complete_url(backend_name):
@@ -241,7 +278,7 @@ def get_disconnect_url(provider_name):
return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name)
def get_login_url(provider_name, auth_entry):
def get_login_url(provider_name, auth_entry, redirect_url=None, enroll_course_id=None):
"""Gets the login URL for the endpoint that kicks off auth with a provider.
Args:
@@ -251,6 +288,13 @@ def get_login_url(provider_name, auth_entry):
for the auth pipeline. Used by the pipeline for later branching.
Must be one of _AUTH_ENTRY_CHOICES.
Keyword Args:
redirect_url (string): If provided, redirect to this URL at the end
of the authentication process.
enroll_course_id (string): If provided, auto-enroll the user in this
course upon successful authentication.
Returns:
String. URL that starts the auth pipeline for a provider.
@@ -259,7 +303,13 @@ def get_login_url(provider_name, auth_entry):
"""
assert auth_entry in _AUTH_ENTRY_CHOICES
enabled_provider = _get_enabled_provider_by_name(provider_name)
return _get_url('social:begin', enabled_provider.BACKEND_CLASS.name, auth_entry=auth_entry)
return _get_url(
'social:begin',
enabled_provider.BACKEND_CLASS.name,
auth_entry=auth_entry,
redirect_url=redirect_url,
enroll_course_id=enroll_course_id
)
def get_duplicate_provider(messages):
@@ -423,8 +473,54 @@ def redirect_to_supplementary_form(
if is_register_2 and user_unset:
return redirect(reverse(AUTH_ENTRY_REGISTER_2))
@partial.partial
def login_analytics(*args, **kwargs):
def set_logged_in_cookie(backend=None, user=None, request=None, *args, **kwargs):
"""This pipeline step sets the "logged in" cookie for authenticated users.
Some installations have a marketing site front-end separate from
edx-platform. Those installations sometimes display different
information for logged in versus anonymous users (e.g. a link
to the student dashboard instead of the login page.)
Since social auth uses Django's native `login()` method, it bypasses
our usual login view that sets this cookie. For this reason, we need
to set the cookie ourselves within the pipeline.
The procedure for doing this is a little strange. On the one hand,
we need to send a response to the user in order to set the cookie.
On the other hand, we don't want to drop the user out of the pipeline.
For this reason, we send a redirect back to the "complete" URL,
so users immediately re-enter the pipeline. The redirect response
contains a header that sets the logged in cookie.
If the user is not logged in, or the logged in cookie is already set,
the function returns `None`, indicating that control should pass
to the next pipeline step.
"""
if user is not None and user.is_authenticated():
if request is not None:
# Check that the cookie isn't already set.
# This ensures that we allow the user to continue to the next
# pipeline step once he/she has the cookie set by this step.
has_cookie = student.helpers.is_logged_in_cookie_set(request)
if not has_cookie:
try:
redirect_url = get_complete_url(backend.name)
except ValueError:
# If for some reason we can't get the URL, just skip this step
# This may be overly paranoid, but it's far more important that
# the user log in successfully than that the cookie is set.
pass
else:
response = redirect(redirect_url)
return student.helpers.set_logged_in_cookie(request, response)
@partial.partial
def login_analytics(strategy, *args, **kwargs):
""" Sends login info to Segment.io """
event_name = None
@@ -447,14 +543,13 @@ def login_analytics(*args, **kwargs):
event_name = action_to_event_name[action]
if event_name is not None:
registration_course_id = kwargs['request'].session.get('registration_course_id')
tracking_context = tracker.get_tracker().resolve_context()
analytics.track(
kwargs['user'].id,
event_name,
{
'category': "conversion",
'label': registration_course_id,
'label': strategy.session_get('enroll_course_id'),
'provider': getattr(kwargs['backend'], 'name')
},
context={
@@ -464,21 +559,54 @@ def login_analytics(*args, **kwargs):
}
)
#@partial.partial
def change_enrollment(*args, **kwargs):
@partial.partial
def change_enrollment(strategy, user=None, *args, **kwargs):
"""Enroll a user in a course.
If a user entered the authentication flow when trying to enroll
in a course, then attempt to enroll the user.
We will try to do this if the pipeline was started with the
querystring param `enroll_course_id`.
In the following cases, we can't enroll the user:
* The course does not have an honor mode.
* The course has an honor mode with a minimum price.
* The course is not yet open for enrollment.
* The course does not exist.
If we can't enroll the user now, then skip this step.
For paid courses, users will be redirected to the payment flow
upon completion of the authentication pipeline
(configured using the ?next parameter to the third party auth login url).
"""
If the user accessed the third party auth flow after trying to register for
a course, we automatically log them into that course.
"""
if kwargs['strategy'].session_get('registration_course_id'):
try:
CourseEnrollment.enroll(
kwargs['user'],
SlashSeparatedCourseKey.from_deprecated_string(
kwargs['strategy'].session_get('registration_course_id')
)
)
except CourseEnrollmentException:
pass
except Exception, e:
logger.exception(e)
enroll_course_id = strategy.session_get('enroll_course_id')
if enroll_course_id:
course_id = CourseKey.from_string(enroll_course_id)
modes = CourseMode.modes_for_course_dict(course_id)
if CourseMode.can_auto_enroll(course_id, modes_dict=modes):
try:
CourseEnrollment.enroll(user, course_id, check_access=True)
except CourseEnrollmentException:
pass
except Exception as ex:
logger.exception(ex)
# Handle white-label courses as a special case
# If a course is white-label, we should add it to the shopping cart.
elif CourseMode.is_white_label(course_id, modes_dict=modes):
try:
cart = Order.get_cart_for_user(user)
PaidCourseRegistration.add_to_order(cart, course_id)
except (
CourseDoesNotExistException,
ItemAlreadyInCartException,
AlreadyEnrolledInCourseException
):
pass
# It's more important to complete login than to
# ensure that the course was added to the shopping cart.
# Log errors, but don't stop the authentication pipeline.
except Exception as ex:
logger.exception(ex)

View File

@@ -46,7 +46,7 @@ If true, it:
from . import provider
_FIELDS_STORED_IN_SESSION = ['auth_entry']
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next', 'enroll_course_id']
_MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
)
@@ -116,6 +116,7 @@ def _set_global_settings(django_settings):
'social.pipeline.social_auth.associate_user',
'social.pipeline.social_auth.load_extra_data',
'social.pipeline.user.user_details',
'third_party_auth.pipeline.set_logged_in_cookie',
'third_party_auth.pipeline.login_analytics',
'third_party_auth.pipeline.change_enrollment',
)

View File

@@ -394,6 +394,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
"""Gets a user by email, using the given strategy."""
return strategy.storage.user.user_model().objects.get(email=email)
def assert_logged_in_cookie_redirect(self, response):
"""Verify that the user was redirected in order to set the logged in cookie. """
self.assertEqual(response.status_code, 302)
self.assertEqual(
response["Location"],
pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name)
)
self.assertEqual(response.cookies[django_settings.EDXMKTG_COOKIE_NAME].value, 'true')
def set_logged_in_cookie(self, request):
"""Simulate setting the marketing site cookie on the request. """
request.COOKIES[django_settings.EDXMKTG_COOKIE_NAME] = 'true'
# Actual tests, executed once per child.
def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self):
@@ -430,6 +443,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), request.user, linked=False)
self.assert_social_auth_does_not_exist_for_user(request.user, strategy)
# We should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME
))
# Set the cookie and try again
self.set_logged_in_cookie(request)
# Fire off the auth pipeline to link.
self.assert_redirect_to_dashboard_looks_correct(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
@@ -449,6 +472,9 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
strategy, 'user@example.com', 'password', self.get_username())
self.assert_social_auth_exists_for_user(user, strategy)
# We're already logged in, so simulate that the cookie is set correctly
self.set_logged_in_cookie(request)
# Instrument the pipeline to get to the dashboard with the full
# expected state.
self.client.get(
@@ -561,6 +587,17 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# redirects to /auth/complete. In the browser ajax handlers will
# redirect the user to the dashboard; we invoke it manually here.
self.assert_json_success_response_looks_correct(student_views.login_user(strategy.request))
# We should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME
))
# Set the cookie and try again
self.set_logged_in_cookie(request)
self.assert_redirect_to_dashboard_looks_correct(
actions.do_complete(strategy, social_views._do_login, user=user))
self.assert_dashboard_response_looks_correct(student_views.dashboard(request), user)
@@ -652,6 +689,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# social auth.
self.assert_social_auth_does_not_exist_for_user(created_user, strategy)
# We should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete(
request.social_strategy, social_views._do_login, request.user, None, # pylint: disable-msg=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME
))
# Set the cookie and try again
self.set_logged_in_cookie(request)
# Pick the pipeline back up. This will create the account association
# and send the user to the dashboard, where the association will be
# displayed.

View File

@@ -0,0 +1,132 @@
"""Tests for the change enrollment step of the pipeline. """
import datetime
import unittest
import ddt
import pytz
from third_party_auth import pipeline
from shoppingcart.models import Order, PaidCourseRegistration # pylint: disable=F0401
from social.apps.django_app import utils as social_utils
from django.conf import settings
from django.contrib.sessions.backends import cache
from django.test import RequestFactory
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseModeFactory
from student.models import CourseEnrollment
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
THIRD_PARTY_AUTH_CONFIGURED = (
settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and
getattr(settings, 'THIRD_PARTY_AUTH', {})
)
@unittest.skipUnless(THIRD_PARTY_AUTH_CONFIGURED, "Third party auth must be configured")
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@ddt.ddt
class PipelineEnrollmentTest(ModuleStoreTestCase):
"""Test that the pipeline auto-enrolls students upon successful authentication. """
BACKEND_NAME = "google-oauth2"
def setUp(self):
"""Create a test course and user. """
super(PipelineEnrollmentTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
@ddt.data(
([], "honor"),
(["honor", "verified", "audit"], "honor"),
(["professional"], None)
)
@ddt.unpack
def test_auto_enroll_step(self, course_modes, enrollment_mode):
# Create the course modes for the test case
for mode_slug in course_modes:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode_slug,
mode_display_name=mode_slug.capitalize()
)
# Simulate the pipeline step, passing in a course ID
# to indicate that the user was trying to enroll
# when they started the auth process.
strategy = self._fake_strategy()
strategy.session_set('enroll_course_id', unicode(self.course.id))
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124
self.assertEqual(result, {})
# Check that the user was or was not enrolled
# (this will vary based on the course mode)
if enrollment_mode is not None:
actual_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(actual_mode, enrollment_mode)
else:
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
def test_add_white_label_to_cart(self):
# Create a white label course (honor with a minimum price)
CourseModeFactory.create(
course_id=self.course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Simulate the pipeline step for enrolling in this course
strategy = self._fake_strategy()
strategy.session_set('enroll_course_id', unicode(self.course.id))
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124
self.assertEqual(result, {})
# Expect that the uesr is NOT enrolled in the course
# because the user has not yet paid
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
# Expect that the course was added to the shopping cart
cart = Order.get_cart_for_user(self.user)
self.assertTrue(cart.has_items(PaidCourseRegistration))
order_item = PaidCourseRegistration.objects.get(order=cart)
self.assertEqual(order_item.course_id, self.course.id)
def test_auto_enroll_not_accessible(self):
# Set the course open date in the future
tomorrow = datetime.datetime.now(pytz.utc) + datetime.timedelta(days=1)
self.course.enrollment_start = tomorrow
self.update_course(self.course, self.user.id)
# Finish authentication and try to auto-enroll
# This should fail silently, with no exception
strategy = self._fake_strategy()
strategy.session_set('enroll_course_id', unicode(self.course.id))
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124
self.assertEqual(result, {})
# Verify that we were NOT enrolled
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
def test_no_course_id_skips_enroll(self):
strategy = self._fake_strategy()
result = pipeline.change_enrollment(strategy, 1, user=self.user) # pylint: disable=E1111,E1124
self.assertEqual(result, {})
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
def _fake_strategy(self):
"""Simulate the strategy passed to the pipeline step. """
request = RequestFactory().get(pipeline.get_complete_url(self.BACKEND_NAME))
request.user = self.user
request.session = cache.SessionStore()
return social_utils.load_strategy(
backend=self.BACKEND_NAME, request=request
)

View File

@@ -0,0 +1,174 @@
"""
Classes representing asset & asset thumbnail metadata.
"""
from datetime import datetime
import pytz
from contracts import contract, new_contract
from opaque_keys.edx.keys import CourseKey, AssetKey
new_contract('AssetKey', AssetKey)
new_contract('datetime', datetime)
new_contract('basestring', basestring)
class IncorrectAssetIdType(Exception):
"""
Raised when the asset ID passed-in to create an AssetMetadata or
AssetThumbnailMetadata is of the wrong type.
"""
pass
class AssetMetadata(object):
"""
Stores the metadata associated with a particular course asset. The asset metadata gets stored
in the modulestore.
"""
TOP_LEVEL_ATTRS = ['basename', 'internal_name', 'locked', 'contenttype', 'md5']
EDIT_INFO_ATTRS = ['curr_version', 'prev_version', 'edited_by', 'edited_on']
ALLOWED_ATTRS = TOP_LEVEL_ATTRS + EDIT_INFO_ATTRS
# All AssetMetadata objects should have AssetLocators with this type.
ASSET_TYPE = 'asset'
@contract(asset_id='AssetKey', basename='basestring | None', internal_name='str | None', locked='bool | None', contenttype='basestring | None',
md5='str | None', curr_version='str | None', prev_version='str | None', edited_by='int | None', edited_on='datetime | None')
def __init__(self, asset_id,
basename=None, internal_name=None,
locked=None, contenttype=None, md5=None,
curr_version=None, prev_version=None,
edited_by=None, edited_on=None, field_decorator=None):
"""
Construct a AssetMetadata object.
Arguments:
asset_id (AssetKey): Key identifying this particular asset.
basename (str): Original path to file at asset upload time.
internal_name (str): Name under which the file is stored internally.
locked (bool): If True, only course participants can access the asset.
contenttype (str): MIME type of the asset.
curr_version (str): Current version of the asset.
prev_version (str): Previous version of the asset.
edited_by (str): Username of last user to upload this asset.
edited_on (datetime): Datetime of last upload of this asset.
field_decorator (function): used by strip_key to convert OpaqueKeys to the app's understanding
"""
if asset_id.asset_type != self.ASSET_TYPE:
raise IncorrectAssetIdType()
self.asset_id = asset_id if field_decorator is None else field_decorator(asset_id)
self.basename = basename # Path w/o filename.
self.internal_name = internal_name
self.locked = locked
self.contenttype = contenttype
self.md5 = md5
self.curr_version = curr_version
self.prev_version = prev_version
self.edited_by = edited_by
self.edited_on = edited_on or datetime.now(pytz.utc)
def __repr__(self):
return """AssetMetadata{!r}""".format((
self.asset_id,
self.basename, self.internal_name,
self.locked, self.contenttype, self.md5,
self.curr_version, self.prev_version,
self.edited_by, self.edited_on
))
def update(self, attr_dict):
"""
Set the attributes on the metadata. Ignore all those outside the known fields.
Arguments:
attr_dict: Prop, val dictionary of all attributes to set.
"""
for attr, val in attr_dict.iteritems():
if attr in self.ALLOWED_ATTRS:
setattr(self, attr, val)
def to_mongo(self):
"""
Converts metadata properties into a MongoDB-storable dict.
"""
return {
'filename': self.asset_id.path,
'basename': self.basename,
'internal_name': self.internal_name,
'locked': self.locked,
'contenttype': self.contenttype,
'md5': self.md5,
'edit_info': {
'curr_version': self.curr_version,
'prev_version': self.prev_version,
'edited_by': self.edited_by,
'edited_on': self.edited_on
}
}
@contract(asset_doc='dict | None')
def from_mongo(self, asset_doc):
"""
Fill in all metadata fields from a MongoDB document.
The asset_id prop is initialized upon construction only.
"""
if asset_doc is None:
return
self.basename = asset_doc['basename']
self.internal_name = asset_doc['internal_name']
self.locked = asset_doc['locked']
self.contenttype = asset_doc['contenttype']
self.md5 = asset_doc['md5']
edit_info = asset_doc['edit_info']
self.curr_version = edit_info['curr_version']
self.prev_version = edit_info['prev_version']
self.edited_by = edit_info['edited_by']
self.edited_on = edit_info['edited_on']
class AssetThumbnailMetadata(object):
"""
Stores the metadata associated with the thumbnail of a course asset.
"""
# All AssetThumbnailMetadata objects should have AssetLocators with this type.
ASSET_TYPE = 'thumbnail'
@contract(asset_id='AssetKey', internal_name='str | unicode | None')
def __init__(self, asset_id, internal_name=None, field_decorator=None):
"""
Construct a AssetThumbnailMetadata object.
Arguments:
asset_id (AssetKey): Key identifying this particular asset.
internal_name (str): Name under which the file is stored internally.
"""
if asset_id.asset_type != self.ASSET_TYPE:
raise IncorrectAssetIdType()
self.asset_id = asset_id if field_decorator is None else field_decorator(asset_id)
self.internal_name = internal_name
def __repr__(self):
return """AssetMetadata{!r}""".format((self.asset_id, self.internal_name))
def to_mongo(self):
"""
Converts metadata properties into a MongoDB-storable dict.
"""
return {
'filename': self.asset_id.path,
'internal_name': self.internal_name
}
@contract(thumbnail_doc='dict | None')
def from_mongo(self, thumbnail_doc):
"""
Fill in all metadata fields from a MongoDB document.
The asset_id prop is initialized upon construction only.
"""
if thumbnail_doc is None:
return
self.internal_name = thumbnail_doc['internal_name']

View File

@@ -29,6 +29,8 @@ from xblock.fields import Scope, String, Boolean, Dict, Integer, Float
from .fields import Timedelta, Date
from django.utils.timezone import UTC
from .util.duedate import get_extended_due_date
from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER
from django.conf import settings
log = logging.getLogger("edx.courseware")
@@ -63,9 +65,9 @@ class Randomization(String):
"""
def from_json(self, value):
if value in ("", "true"):
return "always"
return RANDOMIZATION.ALWAYS
elif value == "false":
return "per_student"
return RANDOMIZATION.PER_STUDENT
return value
to_json = from_json
@@ -103,15 +105,15 @@ class CapaFields(object):
max_attempts = Integer(
display_name=_("Maximum Attempts"),
help=_("Defines the number of times a student can try to answer this problem. "
"If the value is not set, infinite attempts are allowed."),
"If the value is not set, infinite attempts are allowed."),
values={"min": 0}, scope=Scope.settings
)
due = Date(help=_("Date that this problem is due by"), scope=Scope.settings)
extended_due = Date(
help=_("Date that this problem is due by for a particular student. This "
"can be set by an instructor, and will override the global due "
"date if it is set to a date that is later than the global due "
"date."),
"can be set by an instructor, and will override the global due "
"date if it is set to a date that is later than the global due "
"date."),
default=None,
scope=Scope.user_state,
)
@@ -122,36 +124,45 @@ class CapaFields(object):
showanswer = String(
display_name=_("Show Answer"),
help=_("Defines when to show the answer to the problem. "
"A default value can be set in Advanced Settings."),
"A default value can be set in Advanced Settings."),
scope=Scope.settings,
default="finished",
default=SHOWANSWER.FINISHED,
values=[
{"display_name": _("Always"), "value": "always"},
{"display_name": _("Answered"), "value": "answered"},
{"display_name": _("Attempted"), "value": "attempted"},
{"display_name": _("Closed"), "value": "closed"},
{"display_name": _("Finished"), "value": "finished"},
{"display_name": _("Correct or Past Due"), "value": "correct_or_past_due"},
{"display_name": _("Past Due"), "value": "past_due"},
{"display_name": _("Never"), "value": "never"}]
{"display_name": _("Always"), "value": SHOWANSWER.ALWAYS},
{"display_name": _("Answered"), "value": SHOWANSWER.ANSWERED},
{"display_name": _("Attempted"), "value": SHOWANSWER.ATTEMPTED},
{"display_name": _("Closed"), "value": SHOWANSWER.CLOSED},
{"display_name": _("Finished"), "value": SHOWANSWER.FINISHED},
{"display_name": _("Correct or Past Due"), "value": SHOWANSWER.CORRECT_OR_PAST_DUE},
{"display_name": _("Past Due"), "value": SHOWANSWER.PAST_DUE},
{"display_name": _("Never"), "value": SHOWANSWER.NEVER}]
)
force_save_button = Boolean(
help=_("Whether to force the save button to appear on the page"),
scope=Scope.settings,
default=False
)
reset_key = "DEFAULT_SHOW_RESET_BUTTON"
default_reset_button = getattr(settings, reset_key) if hasattr(settings, reset_key) else False
show_reset_button = Boolean(
display_name=_("Show Reset Button"),
help=_("Determines whether a 'Reset' button is shown so the user may reset their answer. "
"A default value can be set in Advanced Settings."),
scope=Scope.settings,
default=default_reset_button
)
rerandomize = Randomization(
display_name=_("Randomization"),
help=_("Defines how often inputs are randomized when a student loads the problem. "
"This setting only applies to problems that can have randomly generated numeric values. "
"A default value can be set in Advanced Settings."),
default="never",
"This setting only applies to problems that can have randomly generated numeric values. "
"A default value can be set in Advanced Settings."),
default=RANDOMIZATION.NEVER,
scope=Scope.settings,
values=[
{"display_name": _("Always"), "value": "always"},
{"display_name": _("On Reset"), "value": "onreset"},
{"display_name": _("Never"), "value": "never"},
{"display_name": _("Per Student"), "value": "per_student"}
{"display_name": _("Always"), "value": RANDOMIZATION.ALWAYS},
{"display_name": _("On Reset"), "value": RANDOMIZATION.ONRESET},
{"display_name": _("Never"), "value": RANDOMIZATION.NEVER},
{"display_name": _("Per Student"), "value": RANDOMIZATION.PER_STUDENT}
]
)
data = String(help=_("XML data for the problem"), scope=Scope.content, default="<problem></problem>")
@@ -170,7 +181,7 @@ class CapaFields(object):
weight = Float(
display_name=_("Problem Weight"),
help=_("Defines the number of points each problem is worth. "
"If the value is not set, each response field in the problem is worth one point."),
"If the value is not set, each response field in the problem is worth one point."),
values={"min": 0, "step": .1},
scope=Scope.settings
)
@@ -254,7 +265,7 @@ class CapaMixin(CapaFields):
tb=cgi.escape(
u''.join(['Traceback (most recent call last):\n'] +
traceback.format_tb(sys.exc_info()[2])))
)
)
# create a dummy problem with error message instead of failing
problem_text = (u'<problem><text><span class="inline-error">'
u'Problem {url} has an error:</span>{msg}</text></problem>'.format(
@@ -274,9 +285,9 @@ class CapaMixin(CapaFields):
"""
Choose a new seed.
"""
if self.rerandomize == 'never':
if self.rerandomize == RANDOMIZATION.NEVER:
self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.runtime, 'seed'):
elif self.rerandomize == RANDOMIZATION.PER_STUDENT and hasattr(self.runtime, 'seed'):
# see comment on randomization_bin
self.seed = randomization_bin(self.runtime.seed, unicode(self.location).encode('utf-8'))
else:
@@ -446,7 +457,7 @@ class CapaMixin(CapaFields):
"""
Return True/False to indicate whether to show the "Check" button.
"""
submitted_without_reset = (self.is_submitted() and self.rerandomize == "always")
submitted_without_reset = (self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS)
# If the problem is closed (past due / too many attempts)
# then we do NOT show the "check" button
@@ -463,19 +474,20 @@ class CapaMixin(CapaFields):
"""
is_survey_question = (self.max_attempts == 0)
if self.rerandomize in ["always", "onreset"]:
# If the problem is closed (and not a survey question with max_attempts==0),
# then do NOT show the reset button.
if (self.closed() and not is_survey_question):
return False
# If the problem is closed (and not a survey question with max_attempts==0),
# then do NOT show the reset button.
# If the problem hasn't been submitted yet, then do NOT show
# the reset button.
if (self.closed() and not is_survey_question) or not self.is_submitted():
# Button only shows up for randomized problems if the question has been submitted
if self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET] and self.is_submitted():
return True
else:
# Do NOT show the button if the problem is correct
if self.is_correct():
return False
else:
return True
# Only randomized problems need a "reset" button
else:
return False
return self.show_reset_button
def should_show_save_button(self):
"""
@@ -489,7 +501,7 @@ class CapaMixin(CapaFields):
return not self.closed()
else:
is_survey_question = (self.max_attempts == 0)
needs_reset = self.is_submitted() and self.rerandomize == "always"
needs_reset = self.is_submitted() and self.rerandomize == RANDOMIZATION.ALWAYS
# If the student has unlimited attempts, and their answers
# are not randomized, then we do not need a save button
@@ -503,7 +515,7 @@ class CapaMixin(CapaFields):
# In those cases. the if statement below is false,
# and the save button can still be displayed.
#
if self.max_attempts is None and self.rerandomize != "always":
if self.max_attempts is None and self.rerandomize != RANDOMIZATION.ALWAYS:
return False
# If the problem is closed (and not a survey question with max_attempts==0),
@@ -697,28 +709,28 @@ class CapaMixin(CapaFields):
"""
if self.showanswer == '':
return False
elif self.showanswer == "never":
elif self.showanswer == SHOWANSWER.NEVER:
return False
elif self.runtime.user_is_staff:
# This is after the 'never' check because admins can see the answer
# unless the problem explicitly prevents it
return True
elif self.showanswer == 'attempted':
elif self.showanswer == SHOWANSWER.ATTEMPTED:
return self.attempts > 0
elif self.showanswer == 'answered':
elif self.showanswer == SHOWANSWER.ANSWERED:
# NOTE: this is slightly different from 'attempted' -- resetting the problems
# makes lcp.done False, but leaves attempts unchanged.
return self.lcp.done
elif self.showanswer == 'closed':
elif self.showanswer == SHOWANSWER.CLOSED:
return self.closed()
elif self.showanswer == 'finished':
elif self.showanswer == SHOWANSWER.FINISHED:
return self.closed() or self.is_correct()
elif self.showanswer == 'correct_or_past_due':
elif self.showanswer == SHOWANSWER.CORRECT_OR_PAST_DUE:
return self.is_correct() or self.is_past_due()
elif self.showanswer == 'past_due':
elif self.showanswer == SHOWANSWER.PAST_DUE:
return self.is_past_due()
elif self.showanswer == 'always':
elif self.showanswer == SHOWANSWER.ALWAYS:
return True
return False
@@ -952,7 +964,7 @@ class CapaMixin(CapaFields):
raise NotFoundError(_("Problem is closed."))
# Problem submitted. Student should reset before checking again
if self.done and self.rerandomize == "always":
if self.done and self.rerandomize == RANDOMIZATION.ALWAYS:
event_info['failure'] = 'unreset'
self.track_function_unmask('problem_check_fail', event_info)
if dog_stats_api:
@@ -1206,7 +1218,7 @@ class CapaMixin(CapaFields):
# was presented to the user, with values interpolated etc, but that can be done
# later if necessary.
variant = ''
if self.rerandomize != 'never':
if self.rerandomize != RANDOMIZATION.NEVER:
variant = self.seed
is_correct = correct_map.is_correct(input_id)
@@ -1333,7 +1345,7 @@ class CapaMixin(CapaFields):
# Problem submitted. Student should reset before saving
# again.
if self.done and self.rerandomize == "always":
if self.done and self.rerandomize == RANDOMIZATION.ALWAYS:
event_info['failure'] = 'done'
self.track_function_unmask('save_problem_fail', event_info)
return {
@@ -1357,7 +1369,7 @@ class CapaMixin(CapaFields):
def reset_problem(self, _data):
"""
Changes problem state to unfinished -- removes student answers,
and causes problem to rerender itself.
Causes problem to rerender itself if randomization is enabled.
Returns a dictionary of the form:
{'success': True/False,
@@ -1380,7 +1392,7 @@ class CapaMixin(CapaFields):
'error': _("Problem is closed."),
}
if not self.done:
if not self.is_submitted():
event_info['failure'] = 'not_done'
self.track_function_unmask('reset_problem_fail', event_info)
return {
@@ -1389,7 +1401,7 @@ class CapaMixin(CapaFields):
'error': _("Refresh the page and make an attempt before resetting."),
}
if self.rerandomize in ["always", "onreset"]:
if self.is_submitted() and self.rerandomize in [RANDOMIZATION.ALWAYS, RANDOMIZATION.ONRESET]:
# Reset random number generator seed.
self.choose_new_seed()

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""
Constants for capa_base problems
"""
class SHOWANSWER:
"""
Constants for when to show answer
"""
ALWAYS = "always"
ANSWERED = "answered"
ATTEMPTED = "attempted"
CLOSED = "closed"
FINISHED = "finished"
CORRECT_OR_PAST_DUE = "correct_or_past_due"
PAST_DUE = "past_due"
NEVER = "never"
class RANDOMIZATION:
"""
Constants for problem randomization
"""
ALWAYS = "always"
ONRESET = "onreset"
NEVER = "never"
PER_STUDENT = "per_student"

View File

@@ -954,10 +954,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
"""Return the course_id for this course"""
return self.location.course_key
@property
def start_date_text(self):
def start_datetime_text(self, format_string="SHORT_DATE"):
"""
Returns the desired text corresponding the course's start date. Prefers .advertised_start,
Returns the desired text corresponding the course's start date and time in UTC. Prefers .advertised_start,
then falls back to .start
"""
i18n = self.runtime.service(self, "i18n")
@@ -970,7 +969,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
if result is None:
result = text.title()
else:
result = strftime(result, "SHORT_DATE")
result = strftime(result, format_string)
if format_string == "DATE_TIME":
result = self._add_timezone_string(result)
except ValueError:
result = text.title()
@@ -984,7 +985,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return _('TBD')
else:
when = self.advertised_start or self.start
return strftime(when, "SHORT_DATE")
if format_string == "DATE_TIME":
return self._add_timezone_string(strftime(when, format_string))
return strftime(when, format_string)
@property
def start_date_is_still_default(self):
@@ -994,10 +999,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
"""
return self.advertised_start is None and self.start == CourseFields.start.default
@property
def end_date_text(self):
def end_datetime_text(self, format_string="SHORT_DATE"):
"""
Returns the end date for the course formatted as a string.
Returns the end date or date_time for the course formatted as a string.
If the course does not have an end date set (course.end is None), an empty string will be returned.
"""
@@ -1005,7 +1009,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return ''
else:
strftime = self.runtime.service(self, "i18n").strftime
return strftime(self.end, "SHORT_DATE")
date_time = strftime(self.end, format_string)
return date_time if format_string == "SHORT_DATE" else self._add_timezone_string(date_time)
def _add_timezone_string(self, date_time):
"""
Adds 'UTC' string to the end of start/end date and time texts.
"""
return date_time + u" UTC"
@property
def forum_posts_allowed(self):

View File

@@ -397,6 +397,14 @@ function (HTML5Video, Resizer) {
}
);
}
// In Html5 mode if video speed is changed before playing in firefox and
// changed speed is not '1.0' then manually trigger setPlaybackRate method.
// In browsers other than firefox like safari user can set speed to '1.0'
// if its not already set to '1.0' so in that case we don't have to
// call 'setPlaybackRate'
if (this.isHtml5Mode() && newSpeed != '1.0') {
this.videoPlayer.player.setPlaybackRate(newSpeed);
}
}
}

View File

@@ -12,23 +12,30 @@ from uuid import uuid4
from collections import namedtuple, defaultdict
import collections
from contextlib import contextmanager
import functools
import threading
from abc import ABCMeta, abstractmethod
from contracts import contract, new_contract
from xblock.plugin import default_select
from .exceptions import InvalidLocationError, InsufficientSpecificationError
from xmodule.errortracker import make_error_tracker
from opaque_keys.edx.keys import CourseKey, UsageKey
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
from opaque_keys.edx.keys import CourseKey, UsageKey, AssetKey
from opaque_keys.edx.locations import Location # For import backwards compatibility
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xblock.runtime import Mixologist
from xblock.core import XBlock
import functools
import threading
log = logging.getLogger('edx.modulestore')
new_contract('CourseKey', CourseKey)
new_contract('AssetKey', AssetKey)
new_contract('AssetMetadata', AssetMetadata)
new_contract('AssetThumbnailMetadata', AssetThumbnailMetadata)
class ModuleStoreEnum(object):
"""
@@ -740,6 +747,9 @@ class ModuleStoreReadBase(BulkOperationsMixin, ModuleStoreRead):
"""
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
"""
Wraps a method to memoize results.
"""
if self.request_cache:
cache_key = '&'.join([hashvalue(arg) for arg in args])
if cache_key in self.request_cache.data.setdefault(func.__name__, {}):
@@ -863,6 +873,276 @@ class ModuleStoreWriteBase(ModuleStoreReadBase, ModuleStoreWrite):
parent.children.append(item.location)
self.update_item(parent, user_id)
def _find_course_assets(self, course_key):
"""
Base method to override.
"""
raise NotImplementedError()
def _find_course_asset(self, course_key, filename, get_thumbnail=False):
"""
Internal; finds or creates course asset info -and- finds existing asset (or thumbnail) metadata.
Arguments:
course_key (CourseKey): course identifier
filename (str): filename of the asset or thumbnail
get_thumbnail (bool): True gets thumbnail data, False gets asset data
Returns:
Asset info for the course, index of asset/thumbnail in list (None if asset/thumbnail does not exist)
"""
course_assets = self._find_course_assets(course_key)
if get_thumbnail:
all_assets = course_assets['thumbnails']
else:
all_assets = course_assets['assets']
# See if this asset already exists by checking the external_filename.
# Studio doesn't currently support using multiple course assets with the same filename.
# So use the filename as the unique identifier.
for idx, asset in enumerate(all_assets):
if asset['filename'] == filename:
return course_assets, idx
return course_assets, None
def _save_asset_info(self, course_key, asset_metadata, user_id, thumbnail=False):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
@contract(course_key='CourseKey', asset_metadata='AssetMetadata')
def save_asset_metadata(self, course_key, asset_metadata, user_id):
"""
Saves the asset metadata for a particular course's asset.
Arguments:
course_key (CourseKey): course identifier
asset_metadata (AssetMetadata): data about the course asset data
Returns:
True if metadata save was successful, else False
"""
return self._save_asset_info(course_key, asset_metadata, user_id, thumbnail=False)
@contract(course_key='CourseKey', asset_thumbnail_metadata='AssetThumbnailMetadata')
def save_asset_thumbnail_metadata(self, course_key, asset_thumbnail_metadata, user_id):
"""
Saves the asset thumbnail metadata for a particular course asset's thumbnail.
Arguments:
course_key (CourseKey): course identifier
asset_thumbnail_metadata (AssetThumbnailMetadata): data about the course asset thumbnail
Returns:
True if thumbnail metadata save was successful, else False
"""
return self._save_asset_info(course_key, asset_thumbnail_metadata, user_id, thumbnail=True)
@contract(asset_key='AssetKey')
def _find_asset_info(self, asset_key, thumbnail=False, **kwargs):
"""
Find the info for a particular course asset/thumbnail.
Arguments:
asset_key (AssetKey): key containing original asset filename
thumbnail (bool): True if finding thumbnail, False if finding asset metadata
Returns:
asset/thumbnail metadata (AssetMetadata/AssetThumbnailMetadata) -or- None if not found
"""
course_assets, asset_idx = self._find_course_asset(asset_key.course_key, asset_key.path, thumbnail)
if asset_idx is None:
return None
if thumbnail:
info = 'thumbnails'
mdata = AssetThumbnailMetadata(asset_key, asset_key.path, **kwargs)
else:
info = 'assets'
mdata = AssetMetadata(asset_key, asset_key.path, **kwargs)
all_assets = course_assets[info]
mdata.from_mongo(all_assets[asset_idx])
return mdata
@contract(asset_key='AssetKey')
def find_asset_metadata(self, asset_key, **kwargs):
"""
Find the metadata for a particular course asset.
Arguments:
asset_key (AssetKey): key containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
return self._find_asset_info(asset_key, thumbnail=False, **kwargs)
@contract(asset_key='AssetKey')
def find_asset_thumbnail_metadata(self, asset_key, **kwargs):
"""
Find the metadata for a particular course asset.
Arguments:
asset_key (AssetKey): key containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
return self._find_asset_info(asset_key, thumbnail=True, **kwargs)
@contract(course_key='CourseKey', start='int | None', maxresults='int | None', sort='list | None', get_thumbnails='bool')
def _get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None, get_thumbnails=False, **kwargs):
"""
Returns a list of static asset (or thumbnail) metadata for a course.
Args:
course_key (CourseKey): course identifier
start (int): optional - start at this asset number
maxresults (int): optional - return at most this many, -1 means no limit
sort (array): optional - None means no sort
(sort_by (str), sort_order (str))
sort_by - one of 'uploadDate' or 'displayname'
sort_order - one of 'ascending' or 'descending'
get_thumbnails (bool): True if getting thumbnail metadata, else getting asset metadata
Returns:
List of AssetMetadata or AssetThumbnailMetadata objects.
"""
course_assets = self._find_course_assets(course_key)
if course_assets is None:
# If no course assets are found, return None instead of empty list
# to distinguish zero assets from "not able to retrieve assets".
return None
if get_thumbnails:
all_assets = course_assets.get('thumbnails', [])
else:
all_assets = course_assets.get('assets', [])
# DO_NEXT: Add start/maxresults/sort functionality as part of https://openedx.atlassian.net/browse/PLAT-74
if start and maxresults and sort:
pass
ret_assets = []
for asset in all_assets:
if get_thumbnails:
thumb = AssetThumbnailMetadata(
course_key.make_asset_key('thumbnail', asset['filename']),
internal_name=asset['filename'], **kwargs
)
ret_assets.append(thumb)
else:
asset = AssetMetadata(
course_key.make_asset_key('asset', asset['filename']),
basename=asset['filename'],
edited_on=asset['edit_info']['edited_on'],
contenttype=asset['contenttype'],
md5=str(asset['md5']), **kwargs
)
ret_assets.append(asset)
return ret_assets
@contract(course_key='CourseKey', start='int | None', maxresults='int | None', sort='list | None')
def get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None, **kwargs):
"""
Returns a list of static assets for a course.
By default all assets are returned, but start and maxresults can be provided to limit the query.
Args:
course_key (CourseKey): course identifier
start (int): optional - start at this asset number
maxresults (int): optional - return at most this many, -1 means no limit
sort (array): optional - None means no sort
(sort_by (str), sort_order (str))
sort_by - one of 'uploadDate' or 'displayname'
sort_order - one of 'ascending' or 'descending'
Returns:
List of AssetMetadata objects.
"""
return self._get_all_asset_metadata(course_key, start, maxresults, sort, get_thumbnails=False, **kwargs)
@contract(course_key='CourseKey')
def get_all_asset_thumbnail_metadata(self, course_key, **kwargs):
"""
Returns a list of thumbnails for all course assets.
Args:
course_key (CourseKey): course identifier
Returns:
List of AssetThumbnailMetadata objects.
"""
return self._get_all_asset_metadata(course_key, get_thumbnails=True, **kwargs)
def set_asset_metadata_attrs(self, asset_key, attrs, user_id):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
def _delete_asset_data(self, asset_key, user_id, thumbnail=False):
"""
Base method to over-ride in modulestore.
"""
raise NotImplementedError()
@contract(asset_key='AssetKey', attr=str)
def set_asset_metadata_attr(self, asset_key, attr, value, user_id):
"""
Add/set the given attr on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr (str): which attribute to set
value: the value to set it to (any type pymongo accepts such as datetime, number, string)
Raises:
ItemNotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
return self.set_asset_metadata_attrs(asset_key, {attr: value}, user_id)
@contract(asset_key='AssetKey')
def delete_asset_metadata(self, asset_key, user_id):
"""
Deletes a single asset's metadata.
Arguments:
asset_key (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
return self._delete_asset_data(asset_key, user_id, thumbnail=False)
@contract(asset_key='AssetKey')
def delete_asset_thumbnail_metadata(self, asset_key, user_id):
"""
Deletes a single asset's metadata.
Arguments:
asset_key (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
return self._delete_asset_data(asset_key, user_id, thumbnail=True)
@contract(source_course_key='CourseKey', dest_course_key='CourseKey')
def copy_all_asset_metadata(self, source_course_key, dest_course_key, user_id):
"""
Copy all the course assets from source_course_key to dest_course_key.
Arguments:
source_course_key (CourseKey): identifier of course to copy from
dest_course_key (CourseKey): identifier of course to copy to
"""
pass
def only_xmodules(identifier, entry_points):
"""Only use entry_points that are supplied by the xmodule package"""

View File

@@ -1,15 +1,16 @@
"""
Support for inheritance of fields down an XBlock hierarchy.
"""
from __future__ import absolute_import
from datetime import datetime
from pytz import UTC
from xmodule.partitions.partitions import UserPartition
from xblock.fields import Scope, Boolean, String, Float, XBlockMixin, Dict, Integer, List
from xblock.runtime import KeyValueStore, KvsFieldData
from xmodule.fields import Date, Timedelta
from django.conf import settings
# Make '_' a no-op so we can scrape strings
_ = lambda text: text
@@ -153,6 +154,16 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.settings
)
reset_key = "DEFAULT_SHOW_RESET_BUTTON"
default_reset_button = getattr(settings, reset_key) if hasattr(settings, reset_key) else False
show_reset_button = Boolean(
display_name=_("Show Reset Button for Problems"),
help=_("Enter true or false. If true, problems default to displaying a 'Reset' button. This value may be "
"overriden in each problem's settings. Existing problems whose reset setting have not been changed are affected."),
scope=Scope.settings,
default=default_reset_button
)
def compute_inherited_metadata(descriptor):
"""Given a descriptor, traverse all of its descendants and do metadata

View File

@@ -9,10 +9,12 @@ import logging
from contextlib import contextmanager
import itertools
import functools
from contracts import contract, new_contract
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.keys import CourseKey, AssetKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
from . import ModuleStoreWriteBase
from . import ModuleStoreEnum
@@ -20,6 +22,10 @@ from .exceptions import ItemNotFoundError, DuplicateCourseError
from .draft_and_published import ModuleStoreDraftAndPublished
from .split_migrator import SplitMigrator
new_contract('CourseKey', CourseKey)
new_contract('AssetKey', AssetKey)
new_contract('AssetMetadata', AssetMetadata)
new_contract('AssetThumbnailMetadata', AssetThumbnailMetadata)
log = logging.getLogger(__name__)
@@ -309,6 +315,189 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
store = self._get_modulestore_for_courseid(course_key)
return store.delete_course(course_key, user_id)
@contract(course_key='CourseKey', asset_metadata='AssetMetadata')
def save_asset_metadata(self, course_key, asset_metadata, user_id):
"""
Saves the asset metadata for a particular course's asset.
Args:
course_key (CourseKey): course identifier
asset_metadata (AssetMetadata): data about the course asset data
"""
store = self._get_modulestore_for_courseid(course_key)
return store.save_asset_metadata(course_key, asset_metadata, user_id)
@contract(course_key='CourseKey', asset_thumbnail_metadata='AssetThumbnailMetadata')
def save_asset_thumbnail_metadata(self, course_key, asset_thumbnail_metadata, user_id):
"""
Saves the asset thumbnail metadata for a particular course asset's thumbnail.
Arguments:
course_key (CourseKey): course identifier
asset_thumbnail_metadata (AssetThumbnailMetadata): data about the course asset thumbnail
"""
store = self._get_modulestore_for_courseid(course_key)
return store.save_asset_thumbnail_metadata(course_key, asset_thumbnail_metadata, user_id)
@strip_key
@contract(asset_key='AssetKey')
def find_asset_metadata(self, asset_key, **kwargs):
"""
Find the metadata for a particular course asset.
Args:
asset_key (AssetKey): locator containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.find_asset_metadata(asset_key, **kwargs)
@strip_key
@contract(asset_key='AssetKey')
def find_asset_thumbnail_metadata(self, asset_key, **kwargs):
"""
Find the metadata for a particular course asset.
Arguments:
asset_key (AssetKey): key containing original asset filename
Returns:
asset metadata (AssetMetadata) -or- None if not found
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.find_asset_thumbnail_metadata(asset_key, **kwargs)
@strip_key
@contract(course_key='CourseKey', start=int, maxresults=int, sort='list | None')
def get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None, **kwargs):
"""
Returns a list of static assets for a course.
By default all assets are returned, but start and maxresults can be provided to limit the query.
Args:
course_key (CourseKey): course identifier
start (int): optional - start at this asset number
maxresults (int): optional - return at most this many, -1 means no limit
sort (array): optional - None means no sort
(sort_by (str), sort_order (str))
sort_by - one of 'uploadDate' or 'displayname'
sort_order - one of 'ascending' or 'descending'
Returns:
List of asset data dictionaries, which have the following keys:
asset_key (AssetKey): asset identifier
displayname: The human-readable name of the asset
uploadDate (datetime.datetime): The date and time that the file was uploaded
contentType: The mimetype string of the asset
md5: An md5 hash of the asset content
"""
store = self._get_modulestore_for_courseid(course_key)
return store.get_all_asset_metadata(course_key, start, maxresults, sort, **kwargs)
@strip_key
@contract(course_key='CourseKey')
def get_all_asset_thumbnail_metadata(self, course_key, **kwargs):
"""
Returns a list of thumbnails for all course assets.
Args:
course_key (CourseKey): course identifier
Returns:
List of AssetThumbnailMetadata objects.
"""
store = self._get_modulestore_for_courseid(course_key)
return store.get_all_asset_thumbnail_metadata(course_key, **kwargs)
@contract(asset_key='AssetKey')
def delete_asset_metadata(self, asset_key, user_id):
"""
Deletes a single asset's metadata.
Arguments:
asset_id (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.delete_asset_metadata(asset_key, user_id)
@contract(asset_key='AssetKey')
def delete_asset_thumbnail_metadata(self, asset_key, user_id):
"""
Deletes a single asset's metadata.
Arguments:
asset_key (AssetKey): locator containing original asset filename
Returns:
Number of asset metadata entries deleted (0 or 1)
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.delete_asset_thumbnail_metadata(asset_key, user_id)
@contract(course_key='CourseKey')
def delete_all_asset_metadata(self, course_key, user_id):
"""
Delete all of the assets which use this course_key as an identifier.
Arguments:
course_key (CourseKey): course_identifier
"""
store = self._get_modulestore_for_courseid(course_key)
return store.delete_all_asset_metadata(course_key, user_id)
@contract(source_course_key='CourseKey', dest_course_key='CourseKey')
def copy_all_asset_metadata(self, source_course_key, dest_course_key, user_id):
"""
Copy all the course assets from source_course_key to dest_course_key.
Arguments:
source_course_key (CourseKey): identifier of course to copy from
dest_course_key (CourseKey): identifier of course to copy to
"""
# When implementing this in https://openedx.atlassian.net/browse/PLAT-78 , consider this:
# Check the modulestores of both the source and dest course_keys. If in different modulestores,
# export all asset data from one modulestore and import it into the dest one.
store = self._get_modulestore_for_courseid(source_course_key)
return store.copy_all_asset_metadata(source_course_key, dest_course_key, user_id)
@contract(asset_key='AssetKey', attr=str)
def set_asset_metadata_attr(self, asset_key, attr, value, user_id):
"""
Add/set the given attr on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr (str): which attribute to set
value: the value to set it to (any type pymongo accepts such as datetime, number, string)
Raises:
NotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.set_asset_metadata_attrs(asset_key, {attr: value}, user_id)
@contract(asset_key='AssetKey', attr_dict=dict)
def set_asset_metadata_attrs(self, asset_key, attr_dict, user_id):
"""
Add/set the given dict of attrs on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr_dict (dict): attribute/value pairs to set
Raises:
NotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
store = self._get_modulestore_for_courseid(asset_key.course_key)
return store.set_asset_metadata_attrs(asset_key, attr_dict, user_id)
@strip_key
def get_parent_location(self, location, **kwargs):
"""

View File

@@ -24,6 +24,7 @@ from fs.osfs import OSFS
from path import path
from datetime import datetime
from pytz import UTC
from contracts import contract, new_contract
from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str
@@ -41,12 +42,18 @@ from xmodule.modulestore.inheritance import InheritanceMixin, inherit_metadata,
from xblock.core import XBlock
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys.edx.locator import CourseLocator
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey
from xmodule.exceptions import HeartbeatFailure
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
log = logging.getLogger(__name__)
new_contract('CourseKey', CourseKey)
new_contract('AssetKey', AssetKey)
new_contract('AssetMetadata', AssetMetadata)
new_contract('AssetThumbnailMetadata', AssetThumbnailMetadata)
# sort order that returns DRAFT items first
SORT_REVISION_FAVOR_DRAFT = ('_id.revision', pymongo.DESCENDING)
@@ -195,7 +202,6 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
category = json_data['location']['category']
class_ = self.load_block_type(category)
definition = json_data.get('definition', {})
metadata = json_data.get('metadata', {})
for old_name, new_name in getattr(class_, 'metadata_translations', {}).items():
@@ -443,7 +449,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
super(MongoModuleStore, self).__init__(contentstore=contentstore, **kwargs)
def do_connection(
db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs
db, collection, host, port=27017, tz_aware=True, user=None, password=None, asset_collection=None, **kwargs
):
"""
Create & open the connection, authenticate, and provide pointers to the collection
@@ -460,6 +466,11 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
)
self.collection = self.database[collection]
# Collection which stores asset metadata.
self.asset_collection = None
if asset_collection is not None:
self.asset_collection = self.database[asset_collection]
if user is not None and password is not None:
self.database.authenticate(user, password)
@@ -1436,6 +1447,147 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
field_data = KvsFieldData(kvs)
return field_data
def _find_course_assets(self, course_key):
"""
Internal; finds (or creates) course asset info about all assets for a particular course
Arguments:
course_key (CourseKey): course identifier
Returns:
Asset info for the course
"""
if self.asset_collection is None:
return None
# Using the course_key, find or insert the course asset metadata document.
# A single document exists per course to store the course asset metadata.
course_assets = self.asset_collection.find_one(
{'course_id': unicode(course_key)},
fields=('course_id', 'storage', 'assets', 'thumbnails')
)
if course_assets is None:
# Not found, so create.
course_assets = {'course_id': unicode(course_key), 'storage': 'FILLMEIN-TMP', 'assets': [], 'thumbnails': []}
course_assets['_id'] = self.asset_collection.insert(course_assets)
return course_assets
@contract(course_key='CourseKey', asset_metadata='AssetMetadata | AssetThumbnailMetadata')
def _save_asset_info(self, course_key, asset_metadata, user_id, thumbnail=False):
"""
Saves the info for a particular course's asset/thumbnail.
Arguments:
course_key (CourseKey): course identifier
asset_metadata (AssetMetadata/AssetThumbnailMetadata): data about the course asset/thumbnail
thumbnail (bool): True if saving thumbnail metadata, False if saving asset metadata
Returns:
True if info save was successful, else False
"""
if self.asset_collection is None:
return False
course_assets, asset_idx = self._find_course_asset(course_key, asset_metadata.asset_id.path, thumbnail)
info = 'thumbnails' if thumbnail else 'assets'
all_assets = course_assets[info]
# Set the edited information for assets only - not thumbnails.
if not thumbnail:
asset_metadata.update({'edited_by': user_id, 'edited_on': datetime.now(UTC)})
# Translate metadata to Mongo format.
metadata_to_insert = asset_metadata.to_mongo()
if asset_idx is None:
# Append new metadata.
# Future optimization: Insert in order & binary search to retrieve.
all_assets.append(metadata_to_insert)
else:
# Replace existing metadata.
all_assets[asset_idx] = metadata_to_insert
# Update the document.
self.asset_collection.update({'_id': course_assets['_id']}, {'$set': {info: all_assets}})
return True
@contract(asset_key='AssetKey', attr_dict=dict)
def set_asset_metadata_attrs(self, asset_key, attr_dict, user_id):
"""
Add/set the given dict of attrs on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr_dict (dict): attribute: value pairs to set
Raises:
ItemNotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
if self.asset_collection is None:
return
course_assets, asset_idx = self._find_course_asset(asset_key.course_key, asset_key.path)
if asset_idx is None:
raise ItemNotFoundError(asset_key)
# Form an AssetMetadata.
all_assets = course_assets['assets']
md = AssetMetadata(asset_key, asset_key.path)
md.from_mongo(all_assets[asset_idx])
md.update(attr_dict)
# Generate a Mongo doc from the metadata and update the course asset info.
all_assets[asset_idx] = md.to_mongo()
self.asset_collection.update({'_id': course_assets['_id']}, {"$set": {'assets': all_assets}})
@contract(asset_key='AssetKey')
def _delete_asset_data(self, asset_key, user_id, thumbnail=False):
"""
Internal; deletes a single asset's metadata -or- thumbnail.
Arguments:
asset_key (AssetKey): key containing original asset/thumbnail filename
thumbnail: True if thumbnail deletion, False if asset metadata deletion
Returns:
Number of asset metadata/thumbnail entries deleted (0 or 1)
"""
if self.asset_collection is None:
return 0
course_assets, asset_idx = self._find_course_asset(asset_key.course_key, asset_key.path, get_thumbnail=thumbnail)
if asset_idx is None:
return 0
info = 'thumbnails' if thumbnail else 'assets'
all_asset_info = course_assets[info]
all_asset_info.pop(asset_idx)
# Update the document.
self.asset_collection.update({'_id': course_assets['_id']}, {'$set': {info: all_asset_info}})
return 1
# pylint: disable=unused-argument
@contract(course_key='CourseKey')
def delete_all_asset_metadata(self, course_key, user_id):
"""
Delete all of the assets which use this course_key as an identifier.
Arguments:
course_key (CourseKey): course_identifier
"""
if self.asset_collection is None:
return
# Using the course_id, find the course asset metadata document.
# A single document exists per course to store the course asset metadata.
course_assets = self._find_course_assets(course_key)
self.asset_collection.remove(course_assets['_id'])
def heartbeat(self):
"""
Check that the db is reachable.

View File

@@ -99,7 +99,7 @@ class MongoConnection(object):
Segregation of pymongo functions from the data modeling mechanisms for split modulestore.
"""
def __init__(
self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs
self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, asset_collection=None, **kwargs
):
"""
Create & open the connection, authenticate, and provide pointers to the collections
@@ -114,6 +114,10 @@ class MongoConnection(object):
db
)
# Remove when adding official Split support for asset metadata storage.
if asset_collection:
pass
if user is not None and password is not None:
self.database.authenticate(user, password)

View File

@@ -80,6 +80,7 @@ from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
from xmodule.error_module import ErrorDescriptor
from collections import defaultdict
from types import NoneType
from xmodule.assetstore import AssetMetadata
log = logging.getLogger(__name__)
@@ -1174,7 +1175,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
Find the version_history_depth next versions of this definition. Return as a VersionTree
'''
# TODO implement
raise NotImplementedError()
pass
def create_definition_from_data(self, course_key, new_def_data, category, user_id):
"""
@@ -2120,6 +2121,180 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
"""
return ModuleStoreEnum.Type.split
def _find_course_assets(self, course_key):
"""
Split specific lookup
"""
return self._lookup_course(course_key).structure
def _find_course_asset(self, course_key, filename, get_thumbnail=False):
structure = self._lookup_course(course_key).structure
return structure, self._lookup_course_asset(structure, filename, get_thumbnail)
def _lookup_course_asset(self, structure, filename, get_thumbnail=False):
"""
Find the course asset in the structure or return None if it does not exist
"""
# See if this asset already exists by checking the external_filename.
# Studio doesn't currently support using multiple course assets with the same filename.
# So use the filename as the unique identifier.
accessor = 'thumbnails' if get_thumbnail else 'assets'
for idx, asset in enumerate(structure.get(accessor, [])):
if asset['filename'] == filename:
return idx
return None
def _update_course_assets(self, user_id, asset_key, update_function, get_thumbnail=False):
"""
A wrapper for functions wanting to manipulate assets. Gets and versions the structure,
passes the mutable array for either 'assets' or 'thumbnails' as well as the idx to the function for it to
update, then persists the changed data back into the course.
The update function can raise an exception if it doesn't want to actually do the commit. The
surrounding method probably should catch that exception.
"""
with self.bulk_operations(asset_key.course_key):
original_structure = self._lookup_course(asset_key.course_key).structure
index_entry = self._get_index_if_valid(asset_key.course_key)
new_structure = self.version_structure(asset_key.course_key, original_structure, user_id)
accessor = 'thumbnails' if get_thumbnail else 'assets'
asset_idx = self._lookup_course_asset(new_structure, asset_key.path, get_thumbnail)
new_structure[accessor] = update_function(new_structure.get(accessor, []), asset_idx)
# update index if appropriate and structures
self.update_structure(asset_key.course_key, new_structure)
if index_entry is not None:
# update the index entry if appropriate
self._update_head(asset_key.course_key, index_entry, asset_key.branch, new_structure['_id'])
def _save_asset_info(self, course_key, asset_metadata, user_id, thumbnail=False):
"""
The guts of saving a new or updated asset
"""
metadata_to_insert = asset_metadata.to_mongo()
def _internal_method(all_assets, asset_idx):
"""
Either replace the existing entry or add a new one
"""
if asset_idx is None:
all_assets.append(metadata_to_insert)
else:
all_assets[asset_idx] = metadata_to_insert
return all_assets
return self._update_course_assets(user_id, asset_metadata.asset_id, _internal_method, thumbnail)
@contract(asset_key='AssetKey', attr_dict=dict)
def set_asset_metadata_attrs(self, asset_key, attr_dict, user_id):
"""
Add/set the given dict of attrs on the asset at the given location. Value can be any type which pymongo accepts.
Arguments:
asset_key (AssetKey): asset identifier
attr_dict (dict): attribute: value pairs to set
Raises:
ItemNotFoundError if no such item exists
AttributeError is attr is one of the build in attrs.
"""
def _internal_method(all_assets, asset_idx):
"""
Update the found item
"""
if asset_idx is None:
raise ItemNotFoundError(asset_key)
# Form an AssetMetadata.
mdata = AssetMetadata(asset_key, asset_key.path)
mdata.from_mongo(all_assets[asset_idx])
mdata.update(attr_dict)
# Generate a Mongo doc from the metadata and update the course asset info.
all_assets[asset_idx] = mdata.to_mongo()
return all_assets
self._update_course_assets(user_id, asset_key, _internal_method, False)
@contract(asset_key='AssetKey')
def _delete_asset_data(self, asset_key, user_id, thumbnail=False):
"""
Internal; deletes a single asset's metadata -or- thumbnail.
Arguments:
asset_key (AssetKey): key containing original asset/thumbnail filename
thumbnail: True if thumbnail deletion, False if asset metadata deletion
Returns:
Number of asset metadata/thumbnail entries deleted (0 or 1)
"""
def _internal_method(all_asset_info, asset_idx):
"""
Remove the item if it was found
"""
if asset_idx is None:
raise ItemNotFoundError(asset_key)
all_asset_info.pop(asset_idx)
return all_asset_info
try:
self._update_course_assets(user_id, asset_key, _internal_method, thumbnail)
return 1
except ItemNotFoundError:
return 0
@contract(course_key='CourseKey')
def delete_all_asset_metadata(self, course_key, user_id):
"""
Delete all of the assets which use this course_key as an identifier.
Arguments:
course_key (CourseKey): course_identifier
"""
with self.bulk_operations(course_key):
original_structure = self._lookup_course(course_key).structure
index_entry = self._get_index_if_valid(course_key)
new_structure = self.version_structure(course_key, original_structure, user_id)
new_structure['assets'] = []
new_structure['thumbnails'] = []
# update index if appropriate and structures
self.update_structure(course_key, new_structure)
if index_entry is not None:
# update the index entry if appropriate
self._update_head(course_key, index_entry, course_key.branch, new_structure['_id'])
@contract(source_course_key='CourseKey', dest_course_key='CourseKey')
def copy_all_asset_metadata(self, source_course_key, dest_course_key, user_id):
"""
Copy all the course assets from source_course_key to dest_course_key.
Arguments:
source_course_key (CourseKey): identifier of course to copy from
dest_course_key (CourseKey): identifier of course to copy to
"""
source_structure = self._lookup_course(source_course_key).structure
with self.bulk_operations(dest_course_key):
original_structure = self._lookup_course(dest_course_key).structure
index_entry = self._get_index_if_valid(dest_course_key)
new_structure = self.version_structure(dest_course_key, original_structure, user_id)
new_structure['assets'] = source_structure.get('assets', [])
new_structure['thumbnails'] = source_structure.get('thumbnails', [])
# update index if appropriate and structures
self.update_structure(dest_course_key, new_structure)
if index_entry is not None:
# update the index entry if appropriate
self._update_head(dest_course_key, index_entry, dest_course_key.branch, new_structure['_id'])
def internal_clean_children(self, course_locator):
"""
Only intended for rather low level methods to use. Goes through the children attrs of

View File

@@ -2,7 +2,7 @@
Module for the dual-branch fall-back Draft->Published Versioning ModuleStore
"""
from split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import InsufficientSpecificationError
@@ -13,7 +13,7 @@ from opaque_keys.edx.locator import CourseLocator
from xmodule.modulestore.split_mongo import BlockKey
class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleStore):
class DraftVersioningModuleStore(SplitMongoModuleStore, ModuleStoreDraftAndPublished):
"""
A subclass of Split that supports a dual-branch fall-back versioning framework
with a Draft branch that falls back to a Published branch.
@@ -43,9 +43,9 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
# create any other necessary things as a side effect: ensure they populate the draft branch
# and rely on auto publish to populate the published branch: split's create course doesn't
# call super b/c it needs the auto publish above to have happened before any of the create_items
# in this. The explicit use of SplitMongoModuleStore is intentional
# in this; so, this manually calls the grandparent and above methods.
with self.branch_setting(ModuleStoreEnum.Branch.draft_preferred, item.id):
# pylint: disable=bad-super-call
# NOTE: DO NOT CHANGE THE SUPER. See comment above
super(SplitMongoModuleStore, self).create_course(
org, course, run, user_id, runtime=item.runtime, **kwargs
)
@@ -229,7 +229,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
if revision == ModuleStoreEnum.RevisionOption.draft_preferred:
revision = ModuleStoreEnum.RevisionOption.draft_only
location = self._map_revision_to_branch(location, revision=revision)
return SplitMongoModuleStore.get_parent_location(self, location, **kwargs)
return super(DraftVersioningModuleStore, self).get_parent_location(location, **kwargs)
def get_orphans(self, course_key, **kwargs):
course_key = self._map_revision_to_branch(course_key)
@@ -275,8 +275,7 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
Publishes the subtree under location from the draft branch to the published branch
Returns the newly published item.
"""
SplitMongoModuleStore.copy(
self,
super(DraftVersioningModuleStore, self).copy(
user_id,
# Directly using the replace function rather than the for_branch function
# because for_branch obliterates the version_guid and will lead to missed version conflicts.
@@ -446,3 +445,62 @@ class DraftVersioningModuleStore(ModuleStoreDraftAndPublished, SplitMongoModuleS
if published_block is not None:
setattr(xblock, '_published_by', published_block['edit_info']['edited_by'])
setattr(xblock, '_published_on', published_block['edit_info']['edited_on'])
def _find_asset_info(self, asset_key, thumbnail=False, **kwargs):
return super(DraftVersioningModuleStore, self)._find_asset_info(
self._map_revision_to_branch(asset_key), thumbnail, **kwargs
)
def _get_all_asset_metadata(self, course_key, start=0, maxresults=-1, sort=None, get_thumbnails=False, **kwargs):
return super(DraftVersioningModuleStore, self)._get_all_asset_metadata(
self._map_revision_to_branch(course_key), start, maxresults, sort, get_thumbnails, **kwargs
)
def _update_course_assets(self, user_id, asset_key, update_function, get_thumbnail=False):
"""
Updates both the published and draft branches
"""
# if one call gets an exception, don't do the other call but pass on the exception
super(DraftVersioningModuleStore, self)._update_course_assets(
user_id, self._map_revision_to_branch(asset_key, ModuleStoreEnum.RevisionOption.published_only),
update_function, get_thumbnail
)
super(DraftVersioningModuleStore, self)._update_course_assets(
user_id, self._map_revision_to_branch(asset_key, ModuleStoreEnum.RevisionOption.draft_only),
update_function, get_thumbnail
)
def _find_course_asset(self, course_key, filename, get_thumbnail=False):
return super(DraftVersioningModuleStore, self)._find_course_asset(
self._map_revision_to_branch(course_key), filename, get_thumbnail=get_thumbnail
)
def _find_course_assets(self, course_key):
"""
Split specific lookup
"""
return super(DraftVersioningModuleStore, self)._find_course_assets(
self._map_revision_to_branch(course_key)
)
def delete_all_asset_metadata(self, course_key, user_id):
"""
Deletes from both branches
"""
super(DraftVersioningModuleStore, self).delete_all_asset_metadata(
self._map_revision_to_branch(course_key, ModuleStoreEnum.RevisionOption.published_only), user_id
)
super(DraftVersioningModuleStore, self).delete_all_asset_metadata(
self._map_revision_to_branch(course_key, ModuleStoreEnum.RevisionOption.draft_only), user_id
)
def copy_all_asset_metadata(self, source_course_key, dest_course_key, user_id):
"""
Copies to and from both branches
"""
for revision in [ModuleStoreEnum.RevisionOption.published_only, ModuleStoreEnum.RevisionOption.draft_only]:
super(DraftVersioningModuleStore, self).copy_all_asset_metadata(
self._map_revision_to_branch(source_course_key, revision),
self._map_revision_to_branch(dest_course_key, revision),
user_id
)

View File

@@ -0,0 +1,394 @@
"""
Tests for assetstore using any of the modulestores for metadata. May extend to testing the storage options
too.
"""
from datetime import datetime, timedelta
import pytz
import unittest
import ddt
from xmodule.assetstore import AssetMetadata, AssetThumbnailMetadata
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.test_cross_modulestore_import_export import (
MODULESTORE_SETUPS, MongoContentstoreBuilder,
)
@ddt.ddt
class TestMongoAssetMetadataStorage(unittest.TestCase):
"""
Tests for storing/querying course asset metadata.
"""
def setUp(self):
super(TestMongoAssetMetadataStorage, self).setUp()
self.addTypeEqualityFunc(datetime, self._compare_datetimes)
self.addTypeEqualityFunc(AssetMetadata, self._compare_metadata)
def _compare_metadata(self, mdata1, mdata2, msg=None):
"""
So we can use the below date comparison
"""
if type(mdata1) != type(mdata2):
self.fail(self._formatMessage(msg, u"{} is not same type as {}".format(mdata1, mdata2)))
for attr in mdata1.ALLOWED_ATTRS:
self.assertEqual(getattr(mdata1, attr), getattr(mdata2, attr), msg)
def _compare_datetimes(self, datetime1, datetime2, msg=None):
"""
Don't compare microseconds as mongo doesn't encode below milliseconds
"""
if not timedelta(seconds=-1) < datetime1 - datetime2 < timedelta(seconds=1):
self.fail(self._formatMessage(msg, u"{} != {}".format(datetime1, datetime2)))
def _make_asset_metadata(self, asset_loc):
"""
Make a single test asset metadata.
"""
return AssetMetadata(asset_loc, internal_name='EKMND332DDBK',
basename='pictures/historical', contenttype='image/jpeg',
locked=False, md5='77631ca4f0e08419b70726a447333ab6',
edited_by=ModuleStoreEnum.UserID.test, edited_on=datetime.now(pytz.utc),
curr_version='v1.0', prev_version='v0.95')
def _make_asset_thumbnail_metadata(self, asset_key):
"""
Make a single test asset thumbnail metadata.
"""
return AssetThumbnailMetadata(asset_key, internal_name='ABC39XJUDN2')
def setup_assets(self, course1_key, course2_key, store=None):
"""
Setup assets. Save in store if given
"""
asset_fields = ('filename', 'internal_name', 'basename', 'locked', 'edited_by', 'edited_on', 'curr_version', 'prev_version')
asset1_vals = ('pic1.jpg', 'EKMND332DDBK', 'pix/archive', False, ModuleStoreEnum.UserID.test, datetime.now(pytz.utc), '14', '13')
asset2_vals = ('shout.ogg', 'KFMDONSKF39K', 'sounds', True, ModuleStoreEnum.UserID.test, datetime.now(pytz.utc), '1', None)
asset3_vals = ('code.tgz', 'ZZB2333YBDMW', 'exercises/14', False, ModuleStoreEnum.UserID.test * 2, datetime.now(pytz.utc), 'AB', 'AA')
asset4_vals = ('dog.png', 'PUPY4242X', 'pictures/animals', True, ModuleStoreEnum.UserID.test * 3, datetime.now(pytz.utc), '5', '4')
asset5_vals = ('not_here.txt', 'JJJCCC747', '/dev/null', False, ModuleStoreEnum.UserID.test * 4, datetime.now(pytz.utc), '50', '49')
asset1 = dict(zip(asset_fields[1:], asset1_vals[1:]))
asset2 = dict(zip(asset_fields[1:], asset2_vals[1:]))
asset3 = dict(zip(asset_fields[1:], asset3_vals[1:]))
asset4 = dict(zip(asset_fields[1:], asset4_vals[1:]))
non_existent_asset = dict(zip(asset_fields[1:], asset5_vals[1:]))
# Asset6 and thumbnail6 have equivalent information on purpose.
asset6_vals = ('asset.txt', 'JJJCCC747858', '/dev/null', False, ModuleStoreEnum.UserID.test * 4, datetime.now(pytz.utc), '50', '49')
asset6 = dict(zip(asset_fields[1:], asset6_vals[1:]))
asset1_key = course1_key.make_asset_key('asset', asset1_vals[0])
asset2_key = course1_key.make_asset_key('asset', asset2_vals[0])
asset3_key = course2_key.make_asset_key('asset', asset3_vals[0])
asset4_key = course2_key.make_asset_key('asset', asset4_vals[0])
asset5_key = course2_key.make_asset_key('asset', asset5_vals[0])
asset6_key = course2_key.make_asset_key('asset', asset6_vals[0])
asset1_md = AssetMetadata(asset1_key, **asset1)
asset2_md = AssetMetadata(asset2_key, **asset2)
asset3_md = AssetMetadata(asset3_key, **asset3)
asset4_md = AssetMetadata(asset4_key, **asset4)
asset5_md = AssetMetadata(asset5_key, **non_existent_asset)
asset6_md = AssetMetadata(asset6_key, **asset6)
if store is not None:
store.save_asset_metadata(course1_key, asset1_md, ModuleStoreEnum.UserID.test)
store.save_asset_metadata(course1_key, asset2_md, ModuleStoreEnum.UserID.test)
store.save_asset_metadata(course2_key, asset3_md, ModuleStoreEnum.UserID.test)
store.save_asset_metadata(course2_key, asset4_md, ModuleStoreEnum.UserID.test)
# 5 & 6 are not saved on purpose!
return (asset1_md, asset2_md, asset3_md, asset4_md, asset5_md, asset6_md)
def setup_thumbnails(self, course1_key, course2_key, store=None):
"""
Setup thumbs. Save in store if given
"""
thumbnail_fields = ('filename', 'internal_name')
thumbnail1_vals = ('cat_thumb.jpg', 'XYXYXYXYXYXY')
thumbnail2_vals = ('kitten_thumb.jpg', '123ABC123ABC')
thumbnail3_vals = ('puppy_thumb.jpg', 'ADAM12ADAM12')
thumbnail4_vals = ('meerkat_thumb.jpg', 'CHIPSPONCH14')
thumbnail5_vals = ('corgi_thumb.jpg', 'RON8LDXFFFF10')
thumbnail1 = dict(zip(thumbnail_fields[1:], thumbnail1_vals[1:]))
thumbnail2 = dict(zip(thumbnail_fields[1:], thumbnail2_vals[1:]))
thumbnail3 = dict(zip(thumbnail_fields[1:], thumbnail3_vals[1:]))
thumbnail4 = dict(zip(thumbnail_fields[1:], thumbnail4_vals[1:]))
non_existent_thumbnail = dict(zip(thumbnail_fields[1:], thumbnail5_vals[1:]))
# Asset6 and thumbnail6 have equivalent information on purpose.
thumbnail6_vals = ('asset.txt', 'JJJCCC747858')
thumbnail6 = dict(zip(thumbnail_fields[1:], thumbnail6_vals[1:]))
thumb1_key = course1_key.make_asset_key('thumbnail', thumbnail1_vals[0])
thumb2_key = course1_key.make_asset_key('thumbnail', thumbnail2_vals[0])
thumb3_key = course2_key.make_asset_key('thumbnail', thumbnail3_vals[0])
thumb4_key = course2_key.make_asset_key('thumbnail', thumbnail4_vals[0])
thumb5_key = course2_key.make_asset_key('thumbnail', thumbnail5_vals[0])
thumb6_key = course2_key.make_asset_key('thumbnail', thumbnail6_vals[0])
thumb1_md = AssetThumbnailMetadata(thumb1_key, **thumbnail1)
thumb2_md = AssetThumbnailMetadata(thumb2_key, **thumbnail2)
thumb3_md = AssetThumbnailMetadata(thumb3_key, **thumbnail3)
thumb4_md = AssetThumbnailMetadata(thumb4_key, **thumbnail4)
thumb5_md = AssetThumbnailMetadata(thumb5_key, **non_existent_thumbnail)
thumb6_md = AssetThumbnailMetadata(thumb6_key, **thumbnail6)
if store is not None:
store.save_asset_thumbnail_metadata(course1_key, thumb1_md, ModuleStoreEnum.UserID.test)
store.save_asset_thumbnail_metadata(course1_key, thumb2_md, ModuleStoreEnum.UserID.test)
store.save_asset_thumbnail_metadata(course2_key, thumb3_md, ModuleStoreEnum.UserID.test)
store.save_asset_thumbnail_metadata(course2_key, thumb4_md, ModuleStoreEnum.UserID.test)
# thumb5 and thumb6 are not saved on purpose!
return (thumb1_md, thumb2_md, thumb3_md, thumb4_md, thumb5_md, thumb6_md)
@ddt.data(*MODULESTORE_SETUPS)
def test_save_one_and_confirm(self, storebuilder):
"""
Save the metadata in each store and retrieve it singularly, as all assets, and after deleting all.
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
asset_filename = 'burnside.jpg'
new_asset_loc = course.id.make_asset_key('asset', asset_filename)
# Confirm that the asset's metadata is not present.
self.assertIsNone(store.find_asset_metadata(new_asset_loc))
# Save the asset's metadata.
new_asset_md = self._make_asset_metadata(new_asset_loc)
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
# Find the asset's metadata and confirm it's the same.
found_asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(found_asset_md)
self.assertEquals(new_asset_md, found_asset_md)
# Confirm that only two setup plus one asset's metadata exists.
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 1)
# Delete all metadata and confirm it's gone.
store.delete_all_asset_metadata(course.id, ModuleStoreEnum.UserID.test)
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 0)
# Now delete the non-existent metadata and ensure it doesn't choke
store.delete_all_asset_metadata(course.id, ModuleStoreEnum.UserID.test)
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 0)
@ddt.data(*MODULESTORE_SETUPS)
def test_delete(self, storebuilder):
"""
Delete non_existent and existent metadata
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
# Attempt to delete an asset that doesn't exist.
self.assertEquals(store.delete_asset_metadata(new_asset_loc, ModuleStoreEnum.UserID.test), 0)
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 0)
new_asset_md = self._make_asset_metadata(new_asset_loc)
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
self.assertEquals(store.delete_asset_metadata(new_asset_loc, ModuleStoreEnum.UserID.test), 1)
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 0)
@ddt.data(*MODULESTORE_SETUPS)
def test_find_non_existing_assets(self, storebuilder):
"""
Save multiple metadata in each store and retrieve it singularly, as all assets, and after deleting all.
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
# Find existing asset metadata.
asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNone(asset_md)
@ddt.data(*MODULESTORE_SETUPS)
def test_add_same_asset_twice(self, storebuilder):
"""
Save multiple metadata in each store and retrieve it singularly, as all assets, and after deleting all.
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
new_asset_md = self._make_asset_metadata(new_asset_loc)
# Add asset metadata.
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 1)
# Add *the same* asset metadata.
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
# Still one here?
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 1)
store.delete_all_asset_metadata(course.id, ModuleStoreEnum.UserID.test)
self.assertEquals(len(store.get_all_asset_metadata(course.id)), 0)
@ddt.data(*MODULESTORE_SETUPS)
def test_lock_unlock_assets(self, storebuilder):
"""
Save multiple metadata in each store and retrieve it singularly, as all assets, and after deleting all.
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
new_asset_md = self._make_asset_metadata(new_asset_loc)
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
locked_state = new_asset_md.locked
# Flip the course asset's locked status.
store.set_asset_metadata_attr(new_asset_loc, "locked", not locked_state, ModuleStoreEnum.UserID.test)
# Find the same course and check its locked status.
updated_asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(updated_asset_md)
self.assertEquals(updated_asset_md.locked, not locked_state)
# Now flip it back.
store.set_asset_metadata_attr(new_asset_loc, "locked", locked_state, ModuleStoreEnum.UserID.test)
reupdated_asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(reupdated_asset_md)
self.assertEquals(reupdated_asset_md.locked, locked_state)
ALLOWED_ATTRS = (
('basename', '/new/path'),
('internal_name', 'new_filename.txt'),
('locked', True),
('contenttype', 'image/png'),
('md5', '5346682d948cc3f683635b6918f9b3d0'),
('curr_version', 'v1.01'),
('prev_version', 'v1.0'),
('edited_by', 'Mork'),
('edited_on', datetime(1969, 1, 1, tzinfo=pytz.utc)),
)
DISALLOWED_ATTRS = (
('asset_id', 'IAmBogus'),
)
UNKNOWN_ATTRS = (
('lunch_order', 'burger_and_fries'),
('villain', 'Khan')
)
@ddt.data(*MODULESTORE_SETUPS)
def test_set_all_attrs(self, storebuilder):
"""
Save setting each attr one at a time
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
new_asset_md = self._make_asset_metadata(new_asset_loc)
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
for attr, value in self.ALLOWED_ATTRS:
# Set the course asset's attr.
store.set_asset_metadata_attr(new_asset_loc, attr, value, ModuleStoreEnum.UserID.test)
# Find the same course asset and check its changed attr.
updated_asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(updated_asset_md)
self.assertIsNotNone(getattr(updated_asset_md, attr, None))
self.assertEquals(getattr(updated_asset_md, attr, None), value)
@ddt.data(*MODULESTORE_SETUPS)
def test_set_disallowed_attrs(self, storebuilder):
"""
setting disallowed attrs should fail
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
new_asset_md = self._make_asset_metadata(new_asset_loc)
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
for attr, value in self.DISALLOWED_ATTRS:
original_attr_val = getattr(new_asset_md, attr)
# Set the course asset's attr.
store.set_asset_metadata_attr(new_asset_loc, attr, value, ModuleStoreEnum.UserID.test)
# Find the same course and check its changed attr.
updated_asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(updated_asset_md)
self.assertIsNotNone(getattr(updated_asset_md, attr, None))
# Make sure that the attr is unchanged from its original value.
self.assertEquals(getattr(updated_asset_md, attr, None), original_attr_val)
@ddt.data(*MODULESTORE_SETUPS)
def test_set_unknown_attrs(self, storebuilder):
"""
setting unknown attrs should fail
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
new_asset_loc = course.id.make_asset_key('asset', 'burnside.jpg')
new_asset_md = self._make_asset_metadata(new_asset_loc)
store.save_asset_metadata(course.id, new_asset_md, ModuleStoreEnum.UserID.test)
for attr, value in self.UNKNOWN_ATTRS:
# Set the course asset's attr.
store.set_asset_metadata_attr(new_asset_loc, attr, value, ModuleStoreEnum.UserID.test)
# Find the same course and check its changed attr.
updated_asset_md = store.find_asset_metadata(new_asset_loc)
self.assertIsNotNone(updated_asset_md)
# Make sure the unknown field was *not* added.
with self.assertRaises(AttributeError):
self.assertEquals(getattr(updated_asset_md, attr), value)
@ddt.data(*MODULESTORE_SETUPS)
def test_save_one_thumbnail_and_delete_one_thumbnail(self, storebuilder):
"""
saving and deleting thumbnails
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
thumbnail_filename = 'burn_thumb.jpg'
asset_key = course.id.make_asset_key('thumbnail', thumbnail_filename)
new_asset_thumbnail = self._make_asset_thumbnail_metadata(asset_key)
store.save_asset_thumbnail_metadata(course.id, new_asset_thumbnail, ModuleStoreEnum.UserID.test)
self.assertEquals(len(store.get_all_asset_thumbnail_metadata(course.id)), 1)
self.assertEquals(store.delete_asset_thumbnail_metadata(asset_key, ModuleStoreEnum.UserID.test), 1)
self.assertEquals(len(store.get_all_asset_thumbnail_metadata(course.id)), 0)
@ddt.data(*MODULESTORE_SETUPS)
def test_find_thumbnail(self, storebuilder):
"""
finding thumbnails
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
thumbnail_filename = 'burn_thumb.jpg'
asset_key = course.id.make_asset_key('thumbnail', thumbnail_filename)
new_asset_thumbnail = self._make_asset_thumbnail_metadata(asset_key)
store.save_asset_thumbnail_metadata(course.id, new_asset_thumbnail, ModuleStoreEnum.UserID.test)
self.assertIsNotNone(store.find_asset_thumbnail_metadata(asset_key))
unknown_asset_key = course.id.make_asset_key('thumbnail', 'nosuchfile.jpg')
self.assertIsNone(store.find_asset_thumbnail_metadata(unknown_asset_key))
@ddt.data(*MODULESTORE_SETUPS)
def test_delete_all_thumbnails(self, storebuilder):
"""
deleting all thumbnails
"""
with MongoContentstoreBuilder().build() as contentstore:
with storebuilder.build(contentstore) as store:
course = CourseFactory.create(modulestore=store)
thumbnail_filename = 'burn_thumb.jpg'
asset_key = course.id.make_asset_key('thumbnail', thumbnail_filename)
new_asset_thumbnail = self._make_asset_thumbnail_metadata(asset_key)
store.save_asset_thumbnail_metadata(
course.id, new_asset_thumbnail, ModuleStoreEnum.UserID.test
)
self.assertEquals(len(store.get_all_asset_thumbnail_metadata(course.id)), 1)
store.delete_all_asset_metadata(course.id, ModuleStoreEnum.UserID.test)
self.assertEquals(len(store.get_all_asset_thumbnail_metadata(course.id)), 0)
def test_get_all_assets_with_paging(self):
pass
def test_copy_all_assets(self):
pass

View File

@@ -87,6 +87,7 @@ class MongoModulestoreBuilder(object):
doc_store_config = dict(
db='modulestore{}'.format(random.randint(0, 10000)),
collection='xmodule',
asset_collection='asset_metadata',
**COMMON_DOCSTORE_CONFIG
)

View File

@@ -47,6 +47,7 @@ class TestMixedModuleStore(CourseComparisonTest):
PORT = MONGO_PORT_NUM
DB = 'test_mongo_%s' % uuid4().hex[:5]
COLLECTION = 'modulestore'
ASSET_COLLECTION = 'assetstore'
FS_ROOT = DATA_DIR
DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
@@ -67,6 +68,7 @@ class TestMixedModuleStore(CourseComparisonTest):
'port': PORT,
'db': DB,
'collection': COLLECTION,
'asset_collection': ASSET_COLLECTION,
}
OPTIONS = {
'mappings': {

View File

@@ -45,6 +45,7 @@ HOST = MONGO_HOST
PORT = MONGO_PORT_NUM
DB = 'test_mongo_%s' % uuid4().hex[:5]
COLLECTION = 'modulestore'
ASSET_COLLECTION = 'assetstore'
FS_ROOT = DATA_DIR # TODO (vshnayder): will need a real fs_root for testing load_item
DEFAULT_CLASS = 'xmodule.raw_module.RawDescriptor'
RENDER_TEMPLATE = lambda t_n, d, ctx = None, nsp = 'main': ''
@@ -60,8 +61,10 @@ class ReferenceTestXBlock(XBlock, XModuleMixin):
reference_dict = ReferenceValueDict(scope=Scope.settings)
class TestMongoModuleStore(unittest.TestCase):
'''Tests!'''
class TestMongoModuleStoreBase(unittest.TestCase):
'''
Basic setup for all tests
'''
# Explicitly list the courses to load (don't want the big one)
courses = ['toy', 'simple', 'simple_with_draft', 'test_unicode']
@@ -87,6 +90,13 @@ class TestMongoModuleStore(unittest.TestCase):
cls.connection.drop_database(DB)
cls.connection.close()
@classmethod
def add_asset_collection(cls, doc_store_config):
"""
No asset collection.
"""
pass
@classmethod
def initdb(cls):
# connect to the db
@@ -95,7 +105,10 @@ class TestMongoModuleStore(unittest.TestCase):
'port': PORT,
'db': DB,
'collection': COLLECTION,
#'asset_collection': ASSET_COLLECTION,
}
cls.add_asset_collection(doc_store_config)
# since MongoModuleStore and MongoContentStore are basically assumed to be together, create this class
# as well
content_store = MongoContentStore(HOST, DB, port=PORT)
@@ -136,14 +149,33 @@ class TestMongoModuleStore(unittest.TestCase):
# Destroy the test db.
connection.drop_database(DB)
def setUp(self):
# make a copy for convenience
self.connection = TestMongoModuleStore.connection
self.dummy_user = ModuleStoreEnum.UserID.test
@classmethod
def setUp(cls):
cls.dummy_user = ModuleStoreEnum.UserID.test
def tearDown(self):
@classmethod
def tearDown(cls):
pass
class TestMongoModuleStore(TestMongoModuleStoreBase):
'''Module store tests'''
@classmethod
def add_asset_collection(cls, doc_store_config):
"""
No asset collection - it's not used in the tests below.
"""
pass
@classmethod
def setupClass(cls):
super(TestMongoModuleStore, cls).setupClass()
@classmethod
def teardownClass(cls):
super(TestMongoModuleStore, cls).teardownClass()
def test_init(self):
'''Make sure the db loads'''
ids = list(self.connection[DB][COLLECTION].find({}, {'_id': True}))
@@ -233,7 +265,6 @@ class TestMongoModuleStore(unittest.TestCase):
self.draft_store.get_item(Location('edX', 'test_unicode', '2012_Fall', 'chapter', 'Overview')),
)
def test_find_one(self):
assert_not_none(
self.draft_store._find_one(Location('edX', 'toy', '2012_Fall', 'course', '2012_Fall')),
@@ -632,6 +663,37 @@ class TestMongoModuleStore(unittest.TestCase):
shutil.rmtree(root_dir)
class TestMongoModuleStoreWithNoAssetCollection(TestMongoModuleStore):
'''
Tests a situation where no asset_collection is specified.
'''
@classmethod
def add_asset_collection(cls, doc_store_config):
"""
No asset collection.
"""
pass
@classmethod
def setupClass(cls):
super(TestMongoModuleStoreWithNoAssetCollection, cls).setupClass()
@classmethod
def teardownClass(cls):
super(TestMongoModuleStoreWithNoAssetCollection, cls).teardownClass()
def test_no_asset_collection(self):
courses = self.draft_store.get_courses()
course = courses[0]
# Confirm that no asset collection means no asset metadata.
self.assertEquals(self.draft_store.get_all_asset_metadata(course.id), None)
# Now delete the non-existent asset metadata.
self.draft_store.delete_all_asset_metadata(course.id, ModuleStoreEnum.UserID.test)
# Should still be nothing.
self.assertEquals(self.draft_store.get_all_asset_metadata(course.id), None)
class TestMongoKeyValueStore(object):
"""
Tests for MongoKeyValueStore.

View File

@@ -13,6 +13,7 @@ import random
import os
import textwrap
import unittest
import ddt
from mock import Mock, patch
import webob
@@ -31,6 +32,7 @@ from xblock.fields import ScopeIds
from . import get_test_system
from pytz import UTC
from capa.correctmap import CorrectMap
from ..capa_base_constants import RANDOMIZATION
class CapaFactory(object):
@@ -140,7 +142,6 @@ class CapaFactory(object):
return module
class CapaFactoryWithFiles(CapaFactory):
"""
A factory for creating a Capa problem with files attached.
@@ -182,6 +183,7 @@ if submission[0] == '':
""")
@ddt.ddt
class CapaModuleTest(unittest.TestCase):
def setUp(self):
@@ -540,39 +542,42 @@ class CapaModuleTest(unittest.TestCase):
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 3)
def test_check_problem_resubmitted_with_randomize(self):
rerandomize_values = ['always', 'true']
@ddt.data(
RANDOMIZATION.ALWAYS,
'true'
)
def test_check_problem_resubmitted_with_randomize(self, rerandomize):
# Randomize turned on
module = CapaFactory.create(rerandomize=rerandomize, attempts=0)
for rerandomize in rerandomize_values:
# Randomize turned on
module = CapaFactory.create(rerandomize=rerandomize, attempts=0)
# Simulate that the problem is completed
module.done = True
# Simulate that the problem is completed
module.done = True
# Expect that we cannot submit
with self.assertRaises(xmodule.exceptions.NotFoundError):
get_request_dict = {CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 0)
def test_check_problem_resubmitted_no_randomize(self):
rerandomize_values = ['never', 'false', 'per_student']
for rerandomize in rerandomize_values:
# Randomize turned off
module = CapaFactory.create(rerandomize=rerandomize, attempts=0, done=True)
# Expect that we can submit successfully
# Expect that we cannot submit
with self.assertRaises(xmodule.exceptions.NotFoundError):
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
module.check_problem(get_request_dict)
self.assertEqual(result['success'], 'correct')
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 0)
# Expect that number of attempts IS incremented
self.assertEqual(module.attempts, 1)
@ddt.data(
RANDOMIZATION.NEVER,
'false',
RANDOMIZATION.PER_STUDENT
)
def test_check_problem_resubmitted_no_randomize(self, rerandomize):
# Randomize turned off
module = CapaFactory.create(rerandomize=rerandomize, attempts=0, done=True)
# Expect that we can submit successfully
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
self.assertEqual(result['success'], 'correct')
# Expect that number of attempts IS incremented
self.assertEqual(module.attempts, 1)
def test_check_problem_queued(self):
module = CapaFactory.create(attempts=1)
@@ -813,7 +818,7 @@ class CapaModuleTest(unittest.TestCase):
def test_reset_problem_closed(self):
# pre studio default
module = CapaFactory.create(rerandomize="always")
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS)
# Simulate that the problem is closed
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
@@ -944,35 +949,36 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the result is failure
self.assertTrue('success' in result and not result['success'])
def test_save_problem_submitted_with_randomize(self):
@ddt.data(
RANDOMIZATION.ALWAYS,
'true'
)
def test_save_problem_submitted_with_randomize(self, rerandomize):
# Capa XModule treats 'always' and 'true' equivalently
rerandomize_values = ['always', 'true']
module = CapaFactory.create(rerandomize=rerandomize, done=True)
for rerandomize in rerandomize_values:
module = CapaFactory.create(rerandomize=rerandomize, done=True)
# Try to save
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Try to save
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that we cannot save
self.assertTrue('success' in result and not result['success'])
def test_save_problem_submitted_no_randomize(self):
# Expect that we cannot save
self.assertTrue('success' in result and not result['success'])
@ddt.data(
RANDOMIZATION.NEVER,
'false',
RANDOMIZATION.PER_STUDENT
)
def test_save_problem_submitted_no_randomize(self, rerandomize):
# Capa XModule treats 'false' and 'per_student' equivalently
rerandomize_values = ['never', 'false', 'per_student']
module = CapaFactory.create(rerandomize=rerandomize, done=True)
for rerandomize in rerandomize_values:
module = CapaFactory.create(rerandomize=rerandomize, done=True)
# Try to save
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Try to save
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that we succeed
self.assertTrue('success' in result and result['success'])
# Expect that we succeed
self.assertTrue('success' in result and result['success'])
def test_check_button_name(self):
@@ -1066,7 +1072,7 @@ class CapaModuleTest(unittest.TestCase):
# If user submitted a problem but hasn't reset,
# do NOT show the check button
# Note: we can only reset when rerandomize="always" or "true"
module = CapaFactory.create(rerandomize="always", done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, done=True)
self.assertFalse(module.should_show_check_button())
module = CapaFactory.create(rerandomize="true", done=True)
@@ -1080,13 +1086,13 @@ class CapaModuleTest(unittest.TestCase):
# and we do NOT have a reset button, then we can show the check button
# Setting rerandomize to "never" or "false" ensures that the reset button
# is not shown
module = CapaFactory.create(rerandomize="never", done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.NEVER, done=True)
self.assertTrue(module.should_show_check_button())
module = CapaFactory.create(rerandomize="false", done=True)
self.assertTrue(module.should_show_check_button())
module = CapaFactory.create(rerandomize="per_student", done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.PER_STUDENT, done=True)
self.assertTrue(module.should_show_check_button())
def test_should_show_reset_button(self):
@@ -1101,30 +1107,36 @@ class CapaModuleTest(unittest.TestCase):
module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True)
self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
module = CapaFactory.create(rerandomize="never", done=True)
self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
module = CapaFactory.create(rerandomize="per_student", done=True)
self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
module = CapaFactory.create(rerandomize="false", done=True)
self.assertFalse(module.should_show_reset_button())
# If the user hasn't submitted an answer yet,
# then do NOT show the reset button
module = CapaFactory.create(done=False)
self.assertFalse(module.should_show_reset_button())
# pre studio default value, DO show the reset button
module = CapaFactory.create(rerandomize="always", done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, done=True)
self.assertTrue(module.should_show_reset_button())
# If survey question for capa (max_attempts = 0),
# DO show the reset button
module = CapaFactory.create(rerandomize="always", max_attempts=0, done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, max_attempts=0, done=True)
self.assertTrue(module.should_show_reset_button())
# If the question is not correct
# DO show the reset button
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, max_attempts=0, done=True, correct=False)
self.assertTrue(module.should_show_reset_button())
# If the question is correct and randomization is never
# DO not show the reset button
module = CapaFactory.create(rerandomize=RANDOMIZATION.NEVER, max_attempts=0, done=True, correct=True)
self.assertFalse(module.should_show_reset_button())
# If the question is correct and randomization is always
# Show the reset button
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, max_attempts=0, done=True, correct=True)
self.assertTrue(module.should_show_reset_button())
# Don't show reset button if randomization is turned on and the question is not done
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, show_reset_button=False, done=False)
self.assertFalse(module.should_show_reset_button())
# Show reset button if randomization is turned on and the problem is done
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, show_reset_button=False, done=True)
self.assertTrue(module.should_show_reset_button())
def test_should_show_save_button(self):
@@ -1140,7 +1152,7 @@ class CapaModuleTest(unittest.TestCase):
self.assertFalse(module.should_show_save_button())
# If user submitted a problem but hasn't reset, do NOT show the save button
module = CapaFactory.create(rerandomize="always", done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, done=True)
self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(rerandomize="true", done=True)
@@ -1149,27 +1161,27 @@ class CapaModuleTest(unittest.TestCase):
# If the user has unlimited attempts and we are not randomizing,
# then do NOT show a save button
# because they can keep using "Check"
module = CapaFactory.create(max_attempts=None, rerandomize="never", done=False)
module = CapaFactory.create(max_attempts=None, rerandomize=RANDOMIZATION.NEVER, done=False)
self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(max_attempts=None, rerandomize="false", done=True)
self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(max_attempts=None, rerandomize="per_student", done=True)
module = CapaFactory.create(max_attempts=None, rerandomize=RANDOMIZATION.PER_STUDENT, done=True)
self.assertFalse(module.should_show_save_button())
# pre-studio default, DO show the save button
module = CapaFactory.create(rerandomize="always", done=False)
module = CapaFactory.create(rerandomize=RANDOMIZATION.ALWAYS, done=False)
self.assertTrue(module.should_show_save_button())
# If we're not randomizing and we have limited attempts, then we can save
module = CapaFactory.create(rerandomize="never", max_attempts=2, done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.NEVER, max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
module = CapaFactory.create(rerandomize="false", max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
module = CapaFactory.create(rerandomize="per_student", max_attempts=2, done=True)
module = CapaFactory.create(rerandomize=RANDOMIZATION.PER_STUDENT, max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
# If survey question for capa (max_attempts = 0),
@@ -1197,7 +1209,7 @@ class CapaModuleTest(unittest.TestCase):
# then show it even if we would ordinarily
# require a reset first
module = CapaFactory.create(force_save_button="true",
rerandomize="always",
rerandomize=RANDOMIZATION.ALWAYS,
done=True)
self.assertTrue(module.should_show_save_button())
@@ -1331,48 +1343,66 @@ class CapaModuleTest(unittest.TestCase):
context = render_args[1]
self.assertTrue(error_msg in context['problem']['html'])
def test_random_seed_no_change(self):
@ddt.data(
'false',
'true',
RANDOMIZATION.NEVER,
RANDOMIZATION.PER_STUDENT,
RANDOMIZATION.ALWAYS,
RANDOMIZATION.ONRESET
)
def test_random_seed_no_change(self, rerandomize):
# Run the test for each possible rerandomize value
for rerandomize in ['false', 'never',
'per_student', 'always',
'true', 'onreset']:
module = CapaFactory.create(rerandomize=rerandomize)
# Get the seed
# By this point, the module should have persisted the seed
seed = module.seed
self.assertTrue(seed is not None)
module = CapaFactory.create(rerandomize=rerandomize)
# If we're not rerandomizing, the seed is always set
# to the same value (1)
if rerandomize in ['never']:
self.assertEqual(seed, 1,
msg="Seed should always be 1 when rerandomize='%s'" % rerandomize)
# Get the seed
# By this point, the module should have persisted the seed
seed = module.seed
self.assertTrue(seed is not None)
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# If we're not rerandomizing, the seed is always set
# to the same value (1)
if rerandomize == RANDOMIZATION.NEVER:
self.assertEqual(seed, 1,
msg="Seed should always be 1 when rerandomize='%s'" % rerandomize)
# Expect that the seed is the same
self.assertEqual(seed, module.seed)
# Check the problem
get_request_dict = {CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# Save the problem
module.save_problem(get_request_dict)
# Expect that the seed is the same
self.assertEqual(seed, module.seed)
# Expect that the seed is the same
self.assertEqual(seed, module.seed)
# Save the problem
module.save_problem(get_request_dict)
# Expect that the seed is the same
self.assertEqual(seed, module.seed)
@ddt.data(
'false',
'true',
RANDOMIZATION.NEVER,
RANDOMIZATION.PER_STUDENT,
RANDOMIZATION.ALWAYS,
RANDOMIZATION.ONRESET
)
def test_random_seed_with_reset(self, rerandomize):
"""
Run the test for each possible rerandomize value
"""
def test_random_seed_with_reset(self):
def _reset_and_get_seed(module):
'''
"""
Reset the XModule and return the module's seed
'''
"""
# Simulate submitting an attempt
# We need to do this, or reset_problem() will
# fail with a complaint that we haven't submitted
# fail because it won't re-randomize until the problem has been submitted
# the problem yet.
module.done = True
@@ -1397,45 +1427,83 @@ class CapaModuleTest(unittest.TestCase):
break
return success
# Run the test for each possible rerandomize value
for rerandomize in ['never', 'false', 'per_student',
'always', 'true', 'onreset']:
module = CapaFactory.create(rerandomize=rerandomize)
module = CapaFactory.create(rerandomize=rerandomize, done=True)
# Get the seed
# By this point, the module should have persisted the seed
seed = module.seed
self.assertTrue(seed is not None)
# Get the seed
# By this point, the module should have persisted the seed
seed = module.seed
self.assertTrue(seed is not None)
# We do NOT want the seed to reset if rerandomize
# is set to 'never' -- it should still be 1
# The seed also stays the same if we're randomizing
# 'per_student': the same student should see the same problem
if rerandomize in ['never', 'false', 'per_student']:
self.assertEqual(seed, _reset_and_get_seed(module))
# We do NOT want the seed to reset if rerandomize
# is set to 'never' -- it should still be 1
# The seed also stays the same if we're randomizing
# 'per_student': the same student should see the same problem
if rerandomize in [RANDOMIZATION.NEVER,
'false',
RANDOMIZATION.PER_STUDENT]:
self.assertEqual(seed, _reset_and_get_seed(module))
# Otherwise, we expect the seed to change
# to another valid seed
else:
# Otherwise, we expect the seed to change
# to another valid seed
else:
# Since there's a small chance we might get the
# same seed again, give it 5 chances
# to generate a different seed
success = _retry_and_check(5, lambda: _reset_and_get_seed(module) != seed)
# Since there's a small chance we might get the
# same seed again, give it 5 chances
# to generate a different seed
success = _retry_and_check(5, lambda: _reset_and_get_seed(module) != seed)
self.assertTrue(module.seed is not None)
msg = 'Could not get a new seed from reset after 5 tries'
self.assertTrue(success, msg)
self.assertTrue(module.seed is not None)
msg = 'Could not get a new seed from reset after 5 tries'
self.assertTrue(success, msg)
def test_random_seed_bins(self):
@ddt.data(
'false',
'true',
RANDOMIZATION.NEVER,
RANDOMIZATION.PER_STUDENT,
RANDOMIZATION.ALWAYS,
RANDOMIZATION.ONRESET
)
def test_random_seed_with_reset_question_unsubmitted(self, rerandomize):
"""
Run the test for each possible rerandomize value
"""
def _reset_and_get_seed(module):
"""
Reset the XModule and return the module's seed
"""
# Reset the problem
# By default, the problem is instantiated as unsubmitted
module.reset_problem({})
# Return the seed
return module.seed
module = CapaFactory.create(rerandomize=rerandomize, done=False)
# Get the seed
# By this point, the module should have persisted the seed
seed = module.seed
self.assertTrue(seed is not None)
#the seed should never change because the student hasn't finished the problem
self.assertEqual(seed, _reset_and_get_seed(module))
@ddt.data(
RANDOMIZATION.ALWAYS,
RANDOMIZATION.PER_STUDENT,
'true',
RANDOMIZATION.ONRESET
)
def test_random_seed_bins(self, rerandomize):
# Assert that we are limiting the number of possible seeds.
# Check the conditions that generate random seeds
for rerandomize in ['always', 'per_student', 'true', 'onreset']:
# Get a bunch of seeds, they should all be in 0-999.
for i in range(200):
module = CapaFactory.create(rerandomize=rerandomize)
assert 0 <= module.seed < 1000
# Get a bunch of seeds, they should all be in 0-999.
i = 200
while i > 0:
module = CapaFactory.create(rerandomize=rerandomize)
assert 0 <= module.seed < 1000
i -= 1
@patch('xmodule.capa_base.log')
@patch('xmodule.capa_base.Progress')
@@ -1765,7 +1833,7 @@ class TestProblemCheckTracking(unittest.TestCase):
def test_rerandomized_inputs(self):
factory = CapaFactory
module = factory.create(rerandomize='always')
module = factory.create(rerandomize=RANDOMIZATION.ALWAYS)
answer_input_dict = {
factory.input_key(2): '3.14'

View File

@@ -185,13 +185,13 @@ class IsNewCourseTestCase(unittest.TestCase):
assertion(a_score, b_score)
start_advertised_settings = [
# start, advertised, result, is_still_default
('2012-12-02T12:00', None, 'Dec 02, 2012', False),
('2012-12-02T12:00', '2011-11-01T12:00', 'Nov 01, 2011', False),
('2012-12-02T12:00', 'Spring 2012', 'Spring 2012', False),
('2012-12-02T12:00', 'November, 2011', 'November, 2011', False),
(xmodule.course_module.CourseFields.start.default, None, 'TBD', True),
(xmodule.course_module.CourseFields.start.default, 'January 2014', 'January 2014', False),
# start, advertised, result, is_still_default, date_time_result
('2012-12-02T12:00', None, 'Dec 02, 2012', False, u'Dec 02, 2012 at 12:00 UTC'),
('2012-12-02T12:00', '2011-11-01T12:00', 'Nov 01, 2011', False, u'Nov 01, 2011 at 12:00 UTC'),
('2012-12-02T12:00', 'Spring 2012', 'Spring 2012', False, 'Spring 2012'),
('2012-12-02T12:00', 'November, 2011', 'November, 2011', False, 'November, 2011'),
(xmodule.course_module.CourseFields.start.default, None, 'TBD', True, 'TBD'),
(xmodule.course_module.CourseFields.start.default, 'January 2014', 'January 2014', False, 'January 2014'),
]
@patch('xmodule.course_module.datetime.now')
@@ -200,7 +200,15 @@ class IsNewCourseTestCase(unittest.TestCase):
for s in self.start_advertised_settings:
d = get_dummy_course(start=s[0], advertised_start=s[1])
print "Checking start=%s advertised=%s" % (s[0], s[1])
self.assertEqual(d.start_date_text, s[2])
self.assertEqual(d.start_datetime_text(), s[2])
@patch('xmodule.course_module.datetime.now')
def test_start_date_time_text(self, gmtime_mock):
gmtime_mock.return_value = NOW
for setting in self.start_advertised_settings:
course = get_dummy_course(start=setting[0], advertised_start=setting[1])
print "Checking start=%s advertised=%s" % (setting[0], setting[1])
self.assertEqual(course.start_datetime_text("DATE_TIME"), setting[4])
def test_start_date_is_default(self):
for s in self.start_advertised_settings:
@@ -242,10 +250,18 @@ class IsNewCourseTestCase(unittest.TestCase):
def test_end_date_text(self):
# No end date set, returns empty string.
d = get_dummy_course('2012-12-02T12:00')
self.assertEqual('', d.end_date_text)
self.assertEqual('', d.end_datetime_text())
d = get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00')
self.assertEqual('Sep 04, 2014', d.end_date_text)
self.assertEqual('Sep 04, 2014', d.end_datetime_text())
def test_end_date_time_text(self):
# No end date set, returns empty string.
course = get_dummy_course('2012-12-02T12:00')
self.assertEqual('', course.end_datetime_text("DATE_TIME"))
course = get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00')
self.assertEqual('Sep 04, 2014 at 12:00 UTC', course.end_datetime_text("DATE_TIME"))
class DiscussionTopicsTestCase(unittest.TestCase):

View File

@@ -3,7 +3,7 @@
* @author: Coolite Inc. http://www.coolite.com/
* @date: 2008-05-13
* @copyright: Copyright (c) 2006-2008, Coolite Inc. (http://www.coolite.com/). All rights reserved.
* @license: Licensed under The MIT License. See license.txt and http://www.datejs.com/license/.
* @license: Licensed under The MIT License. See license.txt and http://www.datejs.com/license/.
* @website: http://www.datejs.com/
*/
Date.CultureInfo={name:"en-US",englishName:"English (United States)",nativeName:"English (United States)",dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],abbreviatedDayNames:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],shortestDayNames:["Su","Mo","Tu","We","Th","Fr","Sa"],firstLetterDayNames:["S","M","T","W","T","F","S"],monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],abbreviatedMonthNames:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],amDesignator:"AM",pmDesignator:"PM",firstDayOfWeek:0,twoDigitYearMax:2029,dateElementOrder:"mdy",formatPatterns:{shortDate:"M/d/yyyy",longDate:"dddd, MMMM dd, yyyy",shortTime:"h:mm tt",longTime:"h:mm:ss tt",fullDateTime:"dddd, MMMM dd, yyyy h:mm:ss tt",sortableDateTime:"yyyy-MM-ddTHH:mm:ss",universalSortableDateTime:"yyyy-MM-dd HH:mm:ssZ",rfc1123:"ddd, dd MMM yyyy HH:mm:ss GMT",monthDay:"MMMM dd",yearMonth:"MMMM, yyyy"},regexPatterns:{jan:/^jan(uary)?/i,feb:/^feb(ruary)?/i,mar:/^mar(ch)?/i,apr:/^apr(il)?/i,may:/^may/i,jun:/^jun(e)?/i,jul:/^jul(y)?/i,aug:/^aug(ust)?/i,sep:/^sep(t(ember)?)?/i,oct:/^oct(ober)?/i,nov:/^nov(ember)?/i,dec:/^dec(ember)?/i,sun:/^su(n(day)?)?/i,mon:/^mo(n(day)?)?/i,tue:/^tu(e(s(day)?)?)?/i,wed:/^we(d(nesday)?)?/i,thu:/^th(u(r(s(day)?)?)?)?/i,fri:/^fr(i(day)?)?/i,sat:/^sa(t(urday)?)?/i,future:/^next/i,past:/^last|past|prev(ious)?/i,add:/^(\+|aft(er)?|from|hence)/i,subtract:/^(\-|bef(ore)?|ago)/i,yesterday:/^yes(terday)?/i,today:/^t(od(ay)?)?/i,tomorrow:/^tom(orrow)?/i,now:/^n(ow)?/i,millisecond:/^ms|milli(second)?s?/i,second:/^sec(ond)?s?/i,minute:/^mn|min(ute)?s?/i,hour:/^h(our)?s?/i,week:/^w(eek)?s?/i,month:/^m(onth)?s?/i,day:/^d(ay)?s?/i,year:/^y(ear)?s?/i,shortMeridian:/^(a|p)/i,longMeridian:/^(a\.?m?\.?|p\.?m?\.?)/i,timezone:/^((e(s|d)t|c(s|d)t|m(s|d)t|p(s|d)t)|((gmt)?\s*(\+|\-)\s*\d\d\d\d?)|gmt|utc)/i,ordinalSuffix:/^\s*(st|nd|rd|th)/i,timeContext:/^\s*(\:|a(?!u|p)|p)/i},timezones:[{name:"UTC",offset:"-000"},{name:"GMT",offset:"-000"},{name:"EST",offset:"-0500"},{name:"EDT",offset:"-0400"},{name:"CST",offset:"-0600"},{name:"CDT",offset:"-0500"},{name:"MST",offset:"-0700"},{name:"MDT",offset:"-0600"},{name:"PST",offset:"-0800"},{name:"PDT",offset:"-0700"}]};
@@ -44,7 +44,7 @@ f(this.getUTCDate())+'T'+
f(this.getUTCHours())+':'+
f(this.getUTCMinutes())+':'+
f(this.getUTCSeconds())+'Z"';};}
$P._toString=$P.toString;$P.toString=function(format){var x=this;if(format&&format.length==1){var c=$C.formatPatterns;x.t=x.toString;switch(format){case"d":return x.t(c.shortDate);case"D":return x.t(c.longDate);case"F":return x.t(c.fullDateTime);case"m":return x.t(c.monthDay);case"r":return x.t(c.rfc1123);case"s":return x.t(c.sortableDateTime);case"t":return x.t(c.shortTime);case"T":return x.t(c.longTime);case"u":return x.t(c.universalSortableDateTime);case"y":return x.t(c.yearMonth);}}
if(typeof $P._toString === 'undefined'){$P._toString=$P.toString;}$P.toString=function(format){var x=this;if(format&&format.length==1){var c=$C.formatPatterns;x.t=x.toString;switch(format){case"d":return x.t(c.shortDate);case"D":return x.t(c.longDate);case"F":return x.t(c.fullDateTime);case"m":return x.t(c.monthDay);case"r":return x.t(c.rfc1123);case"s":return x.t(c.sortableDateTime);case"t":return x.t(c.shortTime);case"T":return x.t(c.longTime);case"u":return x.t(c.universalSortableDateTime);case"y":return x.t(c.yearMonth);}}
var ord=function(n){switch(n*1){case 1:case 21:case 31:return"st";case 2:case 22:return"nd";case 3:case 23:return"rd";default:return"th";}};return format?format.replace(/(\\)?(dd?d?d?|MM?M?M?|yy?y?y?|hh?|HH?|mm?|ss?|tt?|S)/g,function(m){if(m.charAt(0)==="\\"){return m.replace("\\","");}
x.h=x.getHours;switch(m){case"hh":return p(x.h()<13?(x.h()===0?12:x.h()):(x.h()-12));case"h":return x.h()<13?(x.h()===0?12:x.h()):(x.h()-12);case"HH":return p(x.h());case"H":return x.h();case"mm":return p(x.getMinutes());case"m":return x.getMinutes();case"ss":return p(x.getSeconds());case"s":return x.getSeconds();case"yyyy":return p(x.getFullYear(),4);case"yy":return p(x.getFullYear());case"dddd":return $C.dayNames[x.getDay()];case"ddd":return $C.abbreviatedDayNames[x.getDay()];case"dd":return p(x.getDate());case"d":return x.getDate();case"MMMM":return $C.monthNames[x.getMonth()];case"MMM":return $C.abbreviatedMonthNames[x.getMonth()];case"MM":return p((x.getMonth()+1));case"M":return x.getMonth()+1;case"t":return x.h()<12?$C.amDesignator.substring(0,1):$C.pmDesignator.substring(0,1);case"tt":return x.h()<12?$C.amDesignator:$C.pmDesignator;case"S":return ord(x.getDate());default:return m;}}):this._toString();};}());
(function(){var $D=Date,$P=$D.prototype,$C=$D.CultureInfo,$N=Number.prototype;$P._orient=+1;$P._nth=null;$P._is=false;$P._same=false;$P._isSecond=false;$N._dateElement="day";$P.next=function(){this._orient=+1;return this;};$D.next=function(){return $D.today().next();};$P.last=$P.prev=$P.previous=function(){this._orient=-1;return this;};$D.last=$D.prev=$D.previous=function(){return $D.today().last();};$P.is=function(){this._is=true;return this;};$P.same=function(){this._same=true;this._isSecond=false;return this;};$P.today=function(){return this.same().day();};$P.weekday=function(){if(this._is){this._is=false;return(!this.is().sat()&&!this.is().sun());}

View File

@@ -1,5 +1,5 @@
/*
Share Annotation Plugin v1.0 (https://github.com/danielcebrian/share-annotator)
Share Annotation Plugin v1.1 (https://github.com/danielcebrian/share-annotator)
Copyright (C) 2014 Daniel Cebrian Robles
License: https://github.com/danielcebrian/share-annotator/blob/master/License.rst
@@ -29,13 +29,13 @@ Annotator.Plugin.Share = (function(_super) {
Share.prototype.options = {
shareIn: ['facebook', 'twitter', 'email', 'google'],
getUrl: {
'facebook':function(title, link, noteText){
'facebook': function(title, link, noteText) {
return 'https://www.facebook.com/sharer/sharer.php?s=100&p[url]=' + link + '&p[title]=' + encodeURIComponent('Open Video Annotation') + '&p[summary]=' + noteText;
},
'twitter':function(title, link, noteText){
'twitter': function(title, link, noteText) {
return 'https://twitter.com/intent/tweet?original_referer=' + link + '&source=tweetbutton&url=' + link + "&via=OpenVideoAnnotation&text=" + encodeURIComponent('I want to share the following Open Video Annotation: ');
},
'google':function(title, link, noteText){
'google': function(title, link, noteText) {
return 'https://plus.google.com/share?url=' + link;
},
'email': function(title, link, noteText){
@@ -46,8 +46,9 @@ Annotator.Plugin.Share = (function(_super) {
};
function Share(element, options) {
if (typeof options!='undefined')
this.options.shareIn = typeof options.shareIn!='undefined'?options.shareIn:this.options.shareIn;
if (typeof options !== 'undefined') {
this.options.shareIn = typeof options.shareIn !== 'undefined' ? options.shareIn : this.options.shareIn;
}
this.buildHTMLShareButton = __bind(this.buildHTMLShareButton, this);
this.runningAPI = __bind(this.runningAPI, this);
this.updateViewer = __bind(this.updateViewer, this);
@@ -74,7 +75,6 @@ Annotator.Plugin.Share = (function(_super) {
var newfield = Annotator.$('<li class="annotator-item">' + this.buildHTMLShareButton('Share without saving:') + '</li>');
Annotator.$(this.field).replaceWith(newfield);
this.field=newfield[0];
// Create the actions for the buttons
this.buttonsActions(this.field, 2, this.options.baseUrl); // 2 is the method of the API that will be for share without saving
@@ -94,25 +94,26 @@ Annotator.Plugin.Share = (function(_super) {
// Share button HTML
Share.prototype.buildHTMLShareButton = function(title, id) {
var title = title || '';
var id = typeof id!='undefined'?'annotationId="' + id + '"':'';
var titleText = title!=''?'<div class="share-text-annotator">' + title + '</div>':'';
var id = typeof id !== 'undefined' ? 'annotationId="' + id + '"' : '';
var titleText = title !== '' ? '<div class="share-text-annotator">' + title + '</div>' : '';
var shareButton = '<div class="share-button-annotator share-button" ' + id + '></div>';
var popup = '<div class="share-popup-overlay-bg" style="z-index:30000000000"><div class="share-popup"><div class="share-popup-items"></div><div class="close-btn">Close</div></div></div>';
// checks to make sure that no popup overlay already exists (though hidden) and creates a new one if it does not exist
if($('.share-popup-overlay-bg').length === 0)
if ($('.share-popup-overlay-bg').length === 0) {
$('.annotator-wrapper').append(popup);
}
return '<div class="share-container-annotator">' + titleText + shareButton + '</div>';
}
// template for the design of the Share Plugin
Share.prototype.buildHTMLPopup = function(title) {
var buttons = '';
if (typeof this.options.shareIn != 'undefined'){
if (typeof this.options.shareIn !== 'undefined'){
this.options.shareIn.forEach(function(item) {
buttons += '<div class="share-' + item + '-annotator share-button">' + item.charAt(0).toUpperCase() + item.slice(1) + '</div>';
});
}
this.uri = (typeof this.uri != 'undefined') ? this.uri : '';
this.uri = (typeof this.uri !== 'undefined') ? this.uri : '';
var title = '<div class="share-popup-title">' + title.replace(":", "") + '</div>';
var copy = '<div class="share-popup-copy">Copy and Share:</div>';
var uri = '<input type="text" class="share-popup-uri" onclick="javascript:this.select();" readonly="true" value="' + this.uri + '">';
@@ -121,7 +122,7 @@ Annotator.Plugin.Share = (function(_super) {
}
// Create the actions for the buttons
Share.prototype.buttonsActions = function(field, method, url, annotation) {
Share.prototype.buttonsActions = function(field, method, url) {
var share = this;
// hide popup when user clicks on close button
@@ -139,18 +140,12 @@ Annotator.Plugin.Share = (function(_super) {
// Share button
$(field).find('.share-button-annotator.share-button').click(function(event) {
event.preventDefault(); // disable normal link function so that it doesn't refresh the page
annotation = share.getAnnotationFromId(event.currentTarget.attributes.annotationid);
var _field = this;
var ovaId = annotation.id;
var title;
if (method == 1) {
title = 'Share';
} else {
title = 'Share without saving';
}
var ovaId = $(this).attr('annotationId');
var title = method === 1 ? 'Share' : 'Share without saving';
// share.uri will be useful for buildHTMLPopup functions
share.uri = share.createAPIURL(method, ovaId, url, annotation);
share.uri = share.createAPIURL(method, ovaId, url);
// display your popup
$('.share-popup-overlay-bg').show();
@@ -159,22 +154,23 @@ Annotator.Plugin.Share = (function(_super) {
$('.share-popup-items').html(share.buildHTMLPopup(title));
// buttons actions
if (typeof share.options.shareIn!='undefined'){
if (typeof share.options.shareIn !== 'undefined') {
share.options.shareIn.forEach(function(item) {
$('.share-' + item + '-annotator.share-button').click(function() {
var url = share.createAPIURL(method, ovaId, url, annotation);
var url = share.createAPIURL(method, ovaId, url);
var title = "Sharing a annotation with Open Video Annotation";
var link = encodeURIComponent(url);
var noteText = share.getSource('ovaText');
var finalUrl = '';
if (method==1){
if (method === 1) {
var viewer = share.annotator.viewer;
var textarea = $(viewer.element).find('div:first').html();
noteText = encodeURIComponent(textarea);
}
finalUrl = typeof share.options.getUrl[item]!='undefined'?share.options.getUrl[item](title, link, noteText):'';
if (typeof share.options.getUrl[item]!='undefined')
finalUrl = typeof share.options.getUrl[item] !== 'undefined' ? share.options.getUrl[item](title, link, noteText) : '';
if (typeof share.options.getUrl[item] !== 'undefined') {
window.open(finalUrl);
}
});
});
}
@@ -182,34 +178,59 @@ Annotator.Plugin.Share = (function(_super) {
};
Share.prototype.createAPIURL = function(method, ovaId, url, annotation) {
Share.prototype.createAPIURL = function(method, ovaId, url) {
var annotator = this.annotator;
var editor = annotator.editor;
var method = method || 1;
var url = annotation.uri || window.location.href;
var url = url || window.location.href;
url += (url.indexOf('?') >= 0)? '&' : '?';
url += (url.indexOf('?') >= 0) ? '&' : '?';
if (method === 1){
var ovaId = (typeof ovaId !='undefined') ? ovaId : '';
if (method === 1) {
var ovaId = (typeof ovaId !== 'undefined') ? ovaId : '';
url += 'ovaId=' + ovaId;
} else if (method === 2){
var ovaStart = this.getSource('ovaStart');
var ovaEnd = this.getSource('ovaEnd');
var ovaText = this.getSource('ovaText');
url += 'ovaStart=' + ovaStart
+ '&ovaEnd=' + ovaEnd
+ '&ovaText=' + ovaText;
if (typeof editor.VideoJS != 'undefined' && editor.VideoJS !== -1){// Video Annotation
var ovaContainer = this.getSource('ovaContainer');
var ovaSrc = this.getSource('ovaSrc');
url += '&ovaContainer=' + ovaContainer
+ '&ovaSrc=' + ovaSrc;
} else {// Text Annotation
var ovastartOffset = this.getSource('ovastartOffset');
var ovaendOffset = this.getSource('ovaendOffset');
url += '&ovastartOffset=' + ovastartOffset + '&ovaendOffset=' + ovaendOffset;
} else if (method === 2) {
var ovaText = this.getSource('ovaText') || " ";
url += 'ovaText=' + ovaText;
if (typeof editor.VideoJS !== 'undefined' && editor.VideoJS !== -1) { // Video Annotation
var ovaStart = this.getSource('ovaStart') || " ";
var ovaEnd = this.getSource('ovaEnd') || " ";
var ovaContainer = this.getSource('ovaContainer') || " ";
var ovaSrc = this.getSource('ovaSrc') || " ";
url += '&ovaStart=' + ovaStart
+ '&ovaEnd=' + ovaEnd
+ '&ovaContainer=' + ovaContainer
+ '&ovaSrc=' + ovaSrc;
} else if (typeof editor.OpenSeaDragon !== 'undefined' && editor.OpenSeaDragon !== -1) { // Image Annotation
var ovaLeft = this.getSource('ovaLeft') || " ";
var ovaTop = this.getSource('ovaTop') || " ";
var ovaWidth = this.getSource('ovaWidth') || " ";
var ovaHeight = this.getSource('ovaHeight') || " ";
var ovaLeftZoom = this.getSource('ovaLeftZoom') || " ";
var ovaTopZoom = this.getSource('ovaTopZoom') || " ";
var ovaWidthZoom = this.getSource('ovaWidthZoom') || " ";
var ovaHeightZoom = this.getSource('ovaHeightZoom') || " ";
var ovaContainer = this.getSource('ovaContainer') || " ";
var ovaSrc = this.getSource('ovaSrc') || " ";
url += '&ovaLeft=' + ovaLeft
+ '&ovaTop=' + ovaTop
+ '&ovaWidth=' + ovaWidth
+ '&ovaHeight=' + ovaHeight
+ '&ovaLeftZoom=' + ovaLeftZoom
+ '&ovaTopZoom=' + ovaTopZoom
+ '&ovaWidthZoom=' + ovaWidthZoom
+ '&ovaHeightZoom=' + ovaHeightZoom
+ '&ovaContainer=' + ovaContainer
+ '&ovaSrc=' + ovaSrc;
} else { // Text Annotation
var ovaStart = this.getSource('ovaStart') || " ";
var ovaEnd = this.getSource('ovaEnd') || " ";
var ovastartOffset = this.getSource('ovastartOffset') || " ";
var ovaendOffset = this.getSource('ovaendOffset') || " ";
url += '&ovaStart=' + ovaStart
+ '&ovaEnd=' + ovaEnd
+ '&ovastartOffset=' + ovastartOffset
+ '&ovaendOffset=' + ovaendOffset;
}
}
return url;
@@ -217,36 +238,68 @@ Annotator.Plugin.Share = (function(_super) {
Share.prototype.getSource = function(source) {
var source = source || '';
if (source == 'ovaId') {// method 1
source=this.annotation.id;
} else {// method 2
if (source === 'ovaId') { // method 1
source = this.annotation.id;
} else { // method 2
var annotator = this.annotator;
var editor = annotator.editor;
var textarea = $(editor.element).find('textarea')[0];
if (source == 'ovaText')
if (source === 'ovaText') {
source = textarea.value;
}
if (typeof editor.VideoJS!='undefined' && editor.VideoJS !== -1){// Video Annotation
if (source == 'ovaContainer')
if (typeof editor.VideoJS !== 'undefined' && editor.VideoJS !== -1) { // Video Annotation
if (source === 'ovaContainer') {
source = editor.VideoJS;
else if (source == 'ovaSrc')
}
else if (source === 'ovaSrc') {
source = annotator.mplayer[editor.VideoJS].tech.options_.source.src;
else if (source == 'ovaStart')
} else if (source === 'ovaStart') {
source = annotator.mplayer[editor.VideoJS].rangeslider.getValues().start;
else if (source == 'ovaEnd')
} else if (source === 'ovaEnd') {
source = annotator.mplayer[editor.VideoJS].rangeslider.getValues().end;
} else {// Text Annotation
}
} else if (typeof editor.OpenSeaDragon !== 'undefined' && editor.OpenSeaDragon !== -1) { // Image Annotation
var annotation = editor.annotation;
if(source == 'ovastartOffset')
source = annotation.ranges[0].startOffset;
else if (source == 'ovaendOffset')
source = annotation.ranges[0].endOffset;
else if (source == 'ovaStart')
source = annotation.ranges[0].start;
else if (source == 'ovaEnd')
source = annotation.ranges[0].end;
if (source === 'ovaLeft') {
source = annotator.osda.rectPosition ? annotator.osda.rectPosition.left : annotation.rangePosition.left;
} else if (source === 'ovaTop') {
source = annotator.osda.rectPosition ? annotator.osda.rectPosition.top : annotation.rangePosition.top;
} else if (source === 'ovaWidth') {
source = annotator.osda.rectPosition ? annotator.osda.rectPosition.width : annotation.rangePosition.width;
} else if (source ==='ovaHeight') {
source = annotator.osda.rectPosition ? annotator.osda.rectPosition.height : annotation.rangePosition.height;
} else if (source === 'ovaLeftZoom') {
source = annotator.osda.viewer.drawer.viewport.getBounds().x;
} else if (source === 'ovaTopZoom') {
source = annotator.osda.viewer.drawer.viewport.getBounds().y;
} else if (source === 'ovaWidthZoom') {
source = annotator.osda.viewer.drawer.viewport.getBounds().width;
} else if (source === 'ovaHeightZoom') {
source = annotator.osda.viewer.drawer.viewport.getBounds().height;
} else if (source === 'ovaContainer') {
source = annotator.osda.viewer.id;
} else if (source === 'ovaSrc') {
var source = annotator.osda.viewer.source;
var tilesUrl = typeof source.tilesUrl !== 'undefined' ? source.tilesUrl : '';
var functionUrl = typeof source.getTileUrl !== 'undefined' ? source.getTileUrl : '';
source = tilesUrl !== '' ? tilesUrl : ('' + functionUrl).replace(/\s+/g, ' '); // - target.src (media source)
}
} else { // Text Annotation
var annotation = editor.annotation;
// if ranges is 0 then it is a comment
if (annotation.ranges.length > 0) {
if(source === 'ovastartOffset') {
source = annotation.ranges[0].startOffset;
} else if (source === 'ovaendOffset') {
source = annotation.ranges[0].endOffset;
} else if (source === 'ovaStart') {
source = annotation.ranges[0].start;
} else if (source === 'ovaEnd') {
source = annotation.ranges[0].end;
}
}
}
}
return encodeURIComponent(source);
@@ -266,36 +319,54 @@ Annotator.Plugin.Share = (function(_super) {
var src = this.getParameterByName('ovaSrc'); // Method 2 (Obligatory)
var text = this.getParameterByName('ovaText'); // Method 2
var user = this.getParameterByName('ovaUser'); // Method 2
var Left = this.getParameterByName('ovaLeft'); // Method 2
var Top = this.getParameterByName('ovaTop'); // Method 2
var Width = this.getParameterByName('ovaWidth'); // Method 2
var Height = this.getParameterByName('ovaHeight'); // Method 2
var leftZoom = this.getParameterByName('ovaLeftZoom'); // Method 2
var topZoom = this.getParameterByName('ovaTopZoom'); // Method 2
var widthZoom = this.getParameterByName('ovaWidthZoom'); // Method 2
var heightZoom = this.getParameterByName('ovaHeightZoom'); // Method 2
var startOffset = this.getParameterByName('ovastartOffset'); // Method 2
var endOffset = this.getParameterByName('ovaendOffset');// Method 2
var endOffset = this.getParameterByName('ovaendOffset'); // Method 2
// remove the variables from the url browser
var stripped_url = top.location.href;
if (ovaId != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaId');
if (start != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaStart');
if (end != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaEnd');
if (container != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaContainer');
if (src != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaSrc');
if (text != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaText');
if (user != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaUser');
if (startOffset != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovastartOffset');
if (endOffset != '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaendOffset');
if (ovaId !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaId');
if (start !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaStart');
if (end !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaEnd');
if (container !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaContainer');
if (src !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaSrc');
if (text !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaText');
if (user !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaUser');
if (Left !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaLeft');
if (Top !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaTop');
if (Width !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaWidth');
if (Height !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaHeight');
if (leftZoom !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaLeftZoom');
if (topZoom !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaTopZoom');
if (widthZoom !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaWidthZoom');
if (heightZoom !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaHeightZoom');
if (startOffset !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovastartOffset');
if (endOffset !== '') stripped_url = this.removeVariableFromURL(stripped_url, 'ovaendOffset');
window.history.pushState("object or string", "Title", stripped_url);
// Method 1 API with the Id of the annotation
// Example: http://danielcebrian.com/annotations/demo.html?&ovaId=wtva_SjnQb2HtqppDihKug
if (ovaId != '') {
$.extend(API, {method:1, ovaId:ovaId});
if (ovaId !== '') {
$.extend(API, {method: 1, ovaId: ovaId});
}
// Method 2 API with all the parameter to load the annotation
// Example with video: http://danielcebrian.com/annotations/demo.html?ovaContainer=vid1&ovaSrc=http%3A%2F%2Fvideo-js.zencoder.com%2Foceans-clip.mp4&ovaStart=2&ovaEnd=10&ovaText=This%20is%20test&ovaUser=Test%20User
// Example with text: http://danielcebrian.com/annotations/demo.html?ovaStart=%2Fp%5B1%5D&ovaEnd=%2Fp%5B1%5D&ovastartOffset=542&ovaendOffset=572&ovaText=API
if (start!='' && end!='' && container!='' && src!='') {// video api
$.extend(API, {method:2, start:start, end:end, container:container, src:src, text:text, user:user});
} else if(start!='' && end!='' && startOffset!='' && endOffset!='') {// text api
$.extend(API, {method:2, start:start, end:end, startOffset:startOffset, endOffset:endOffset, text:text, user:user});
if (start !== '' && end !== '' && container !== '' && src !== '') { // video api
$.extend(API, {method: 2, start: start, end: end, container: container, src: src, text: text, user: user});
} else if (Left !== '' && Top !== '' && Width !== '' && Height !== '' && leftZoom !== '' && topZoom !== '' && widthZoom !== '' && heightZoom !== '') { // image api
$.extend(API, {method: 2, Left: Left, Top: Top, Width: Width, Height: Height, leftZoom: leftZoom, topZoom: topZoom, widthZoom: widthZoom, heightZoom: heightZoom, container: container, src: src, text: text, user: user});
} else if (start !== '' && end !== '' && startOffset !== '' && endOffset !== '') { // text api
$.extend(API, {method: 2, start: start, end: end, startOffset: startOffset, endOffset: endOffset, text: text, user: user});
}
return API;
}
@@ -303,30 +374,31 @@ Annotator.Plugin.Share = (function(_super) {
var wrapper = $('.annotator-wrapper').parent()[0];
var mplayer;
var osda;
var self=this;
var self = this;
// Set Annotator in wrapper to fix quick DOM
$.data(wrapper, 'annotator', self.annotator);// Set the object in the span
annotator = window.annotator = $.data(wrapper, 'annotator');
mplayer = (typeof annotator.mplayer != 'undefined') ? annotator.mplayer : [];
osda = (typeof annotator.osda != 'undefined') ? annotator.osda : [];
mplayer = (typeof annotator.mplayer !== 'undefined') ? annotator.mplayer : [];
osda = (typeof annotator.osda !== 'undefined') ? annotator.osda : [];
// Detect if the URL has an API element
if (typeof API != 'undefined' && typeof API.method != 'undefined' && (API.method == '1' || API.method == '2')) {
if (API.method=='1'){
if (typeof API !== 'undefined' && typeof API.method !== 'undefined' && (parseInt(API.method, 10) === 1 || parseInt(API.method, 10) === 2)) {
if (parseInt(API.method, 10) === 1) {
var allannotations = annotator.plugins['Store'].annotations;
var ovaId = decodeURIComponent(API.ovaId);
var ovaId = parseInt(decodeURIComponent(API.ovaId), 10);
for (var item in allannotations) {
var an = allannotations[item];
if (typeof an.id!='undefined' && an.id == ovaId){// this is the annotation
if (self._isVideo(an)){// It is a video
if (typeof mplayer[an.target.container] != 'undefined'){
var olditem;
if (typeof an.id !== 'undefined' && parseInt(an.id, 10) === ovaId) { // this is the annotation
if (self._isVideo(an)) {// It is a video
if (typeof mplayer[an.target.container] !== 'undefined') {
var player = mplayer[an.target.container];
if (player.id_ == an.target.container){
if (player.id_ === an.target.container) {
var anFound = an;
videojs(player.id_).ready(function(){
if (player.techName != 'Youtube'){
videojs(player.id_).ready(function() {
if (player.techName !== 'Youtube') {
player.preload('auto');
}
player.autoPlayAPI = anFound;
@@ -334,49 +406,84 @@ Annotator.Plugin.Share = (function(_super) {
});
}
}
} else if (self._isImage(an)) {// It is a OpenSeaDragon Annotation
var bounds = new OpenSeadragon.Rect(an.bounds.x, an.bounds.y, an.bounds.width, an.bounds.height);
setTimeout(function() {
osda.viewer.viewport.fitBounds(bounds, false);
$('html, body').animate( {
scrollTop: $("#" + an.target.container).offset().top
}, 'slow');
}, 250);
} else {// It is a text
var hasRanges = typeof an.ranges != 'undefined' && typeof an.ranges[0] != 'undefined';
var startOffset = hasRanges?an.ranges[0].startOffset:'';
var endOffset = hasRanges?an.ranges[0].endOffset:'';
} else if (an.media === "image") { // It is a OpenSeaDragon Annotation
if ($("div#" + an.target.container).length) {
var isOpenViewer = typeof annotator.osda !== "undefined" && typeof annotator.osda.viewer !== "undefined";
function waitingOsda(){
isOpenViewer = typeof annotator.osda !== "undefined" && typeof annotator.osda.viewer !== "undefined";
if (typeof olditem === "undefined") {
olditem = item;
}
if (!isOpenViewer) {
setTimeout(waitingOsda, 200);
} else {
an = allannotations[olditem];
$(an.highlights).parent().find('.annotator-hl').removeClass('api');
// change the color
$(an.highlights).addClass('api');
// change zoom
if (typeof annotator !== 'undefined' && typeof annotator.osda !== 'undefined') {
var currentBounds = annotator.osda.viewer.drawer.viewport.getBounds();
var bounds = typeof an.bounds !== 'undefined' ? an.bounds : {};
if (typeof bounds.x !== 'undefined') {
currentBounds.x = bounds.x;
}
if (typeof bounds.y !== 'undefined') {
currentBounds.y = bounds.y;
}
if (typeof bounds.width !== 'undefined') {
currentBounds.width = bounds.width;
}
if (typeof bounds.height !== 'undefined') {
currentBounds.height = bounds.height;
}
annotator.osda.viewer.drawer.viewport.fitBounds(currentBounds);
}
// animate to the annotation
$('html,body').animate({
scrollTop: $(annotator.osda.viewer.element).offset().top
}, 'slow');
}
}
waitingOsda();
}
} else { // It is a text
var hasRanges = typeof an.ranges !== 'undefined' && typeof an.ranges[0] !== 'undefined';
var startOffset = hasRanges ? an.ranges[0].startOffset : '';
var endOffset = hasRanges ? an.ranges[0].endOffset : '';
if (typeof startOffset != 'undefined' && typeof endOffset != 'undefined'){
if (typeof startOffset !== 'undefined' && typeof endOffset !== 'undefined') {
// change the color
$(an.highlights).addClass('api');
// animate to the annotation
$('html, body').animate({
scrollTop: $(an.highlights[0]).offset().top}, 'slow');
$('html,body').animate({
scrollTop: $(an.highlights[0]).offset().top
}, 'slow');
}
}
}
}
} else if (API.method == '2'){
if (typeof mplayer != 'undefined'){
} else if (parseInt(API.method, 10) === 2) {
if (typeof mplayer !== 'undefined') {
// variable for Video
var container = decodeURIComponent(API.container);
var container = decodeURIComponent(API.container);
var player = mplayer[container];
var isVideo = (typeof player != 'undefined' && container == player.id_);
var isVideo = (typeof player !== 'undefined' && container === player.id_);
var isNumber = (!isNaN(parseFloat(API.start)) && isFinite(API.start) && !isNaN(parseFloat(API.end)) && isFinite(API.end));
var isSource = false;
if (isVideo){
if (isVideo) {
// Compare without extension
var src = decodeURIComponent(API.src);
var targetSrc = src.substring(0, src.lastIndexOf("."));
var playerSrc = (player.tech.options_.source.src == '') ? player.tag.currentSrc : player.tech.options_.source.src;
playerSrc = playerSrc.substring(0, playerSrc.lastIndexOf("."))
isSource = (targetSrc == playerSrc);
var playerSrc = (player.tech.options_.source.src === '') ? player.tag.currentSrc : player.tech.options_.source.src;
playerSrc = playerSrc.substring(0, playerSrc.lastIndexOf("."));
isSource = (targetSrc === playerSrc);
}
// Open Video Annotation
if (isVideo && isNumber && isSource){
if (isVideo && isNumber && isSource) {
var annotation = {
rangeTime: {
start: API.start,
@@ -393,7 +500,7 @@ Annotator.Plugin.Share = (function(_super) {
user: decodeURIComponent(API.user)
};
videojs(player.id_).ready(function(){
if (player.techName != 'Youtube'){
if (player.techName !== 'Youtube'){
player.preload('auto');
}
player.autoPlayAPI = annotation;
@@ -406,7 +513,7 @@ Annotator.Plugin.Share = (function(_super) {
var endOffset = API.endOffset;
// Text Annotation
if (!isVideo && typeof startOffset != 'undefined' && typeof endOffset != 'undefined'){
if (!isVideo && typeof startOffset !== 'undefined' && typeof endOffset !== 'undefined') {
var annotation = {
ranges: [{
start: decodeURIComponent(API.start),
@@ -426,8 +533,79 @@ Annotator.Plugin.Share = (function(_super) {
$(annotation.highlights).addClass('api');
// animate to the annotation
$('html, body').animate({
scrollTop: $(annotation.highlights[0]).offset().top},
'slow');
scrollTop: $(annotation.highlights[0]).offset().top
}, 'slow');
}
// variables for images
var Left = API.Left;
var Top = API.Top;
var Width = API.Width;
var Height = API.Height;
var leftZoom = API.leftZoom;
var topZoom = API.topZoom;
var widthZoom = API.widthZoom;
var heightZoom = API.heightZoom;
// Image Annotation
if (!isVideo && typeof Left !== 'undefined' && typeof Top !== 'undefined' && typeof Width !== 'undefined' && typeof Height !== 'undefined' && typeof leftZoom !== 'undefined' && typeof topZoom !== 'undefined' && typeof widthZoom !== 'undefined' && typeof heightZoom !== 'undefined') {
var an = {
rangePosition: {
width: parseFloat(decodeURIComponent(API.Width)),
top: parseFloat(decodeURIComponent(API.Top)),
left: parseFloat(decodeURIComponent(API.Left)),
height: parseFloat(decodeURIComponent(API.Height)),
},
bounds: {
width: parseFloat(decodeURIComponent(API.widthZoom)),
x: parseFloat(decodeURIComponent(API.leftZoom)),
y: parseFloat(decodeURIComponent(API.topZoom)),
height: parseFloat(decodeURIComponent(API.heightZoom)),
},
target:{
container: API.container,
src: API.src
},
created: new Date().toISOString(),
updated: new Date().toISOString(),
media: 'image',
text: decodeURIComponent(API.text),
user: decodeURIComponent(API.user)
};
var isOpenViewer = typeof annotator.osda !== "undefined" && typeof annotator.osda.viewer !== "undefined";
function waitingOsda() {
isOpenViewer = typeof annotator.osda !== "undefined" && typeof annotator.osda.viewer !== "undefined";
if (!isOpenViewer) {
setTimeout(waitingOsda, 200);
} else {
// show the annotation
annotator.plugins['Store'].annotations.push(an);
annotator.osda.viewer.annotationInstance.drawRect(an);
// change the color
$(an.highlights).addClass('api');
// change zoom
var currentBounds = annotator.osda.viewer.drawer.viewport.getBounds();
var bounds = typeof an.bounds !== 'undefined' ? an.bounds : {};
if (typeof bounds.x !== 'undefined') {
currentBounds.x = bounds.x;
}
if (typeof bounds.y !== 'undefined') {
currentBounds.y = bounds.y;
}
if (typeof bounds.width !== 'undefined') {
currentBounds.width = bounds.width;
}
if (typeof bounds.height !== 'undefined') {
currentBounds.height = bounds.height;
}
annotator.osda.viewer.drawer.viewport.fitBounds(currentBounds);
// animate to the annotation
$('html,body').animate({
scrollTop: $(annotator.osda.viewer.element).offset().top
}, 'slow');
}
}
waitingOsda();
}
}
@@ -447,25 +625,32 @@ Annotator.Plugin.Share = (function(_super) {
.subscribe("annotationsLoaded", func);
}
Share.prototype._isVideo = function(an){
Share.prototype._isVideo = function(an) {
// Detect if the annotation is a Open Video Annotation
var an = an || {};
var rt = an.rangeTime;
var isVideo = (typeof an.media != 'undefined' && an.media == 'video');
var hasContainer = (typeof an.target != 'undefined' && typeof an.target.container != 'undefined' );
var isNumber = (typeof rt != 'undefined' && !isNaN(parseFloat(rt.start)) && isFinite(rt.start) && !isNaN(parseFloat(rt.end)) && isFinite(rt.end));
var isVideo = (typeof an.media !== 'undefined' && an.media === 'video');
var hasContainer = (typeof an.target !== 'undefined' && typeof an.target.container !== 'undefined' );
var isNumber = (typeof rt !== 'undefined' && !isNaN(parseFloat(rt.start)) && isFinite(rt.start) && !isNaN(parseFloat(rt.end)) && isFinite(rt.end));
return (isVideo && hasContainer && isNumber);
}
Share.prototype._isImage = function(annotation) {
return annotation.media === 'image';
var wrapper = $('.annotator-wrapper').parent()[0];
var annotator = window.annotator = $.data(wrapper, 'annotator');
var rp = an.rangePosition;
var isOpenSeaDragon = (typeof annotator.osda !== 'undefined');
var isContainer = (typeof an.target !== 'undefined' && typeof an.target.container !== 'undefined');
var isImage = (typeof an.media !== 'undefined' && an.media === 'image');
var isRP = (typeof rp !== 'undefined');
return (isOpenSeaDragon && isContainer && isImage && isRP);
}
Share.prototype.getParameterByName = function(name) {
name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)");
var results = regex.exec('?' + window.location.href.split('?')[1]);
return results == null ? "" : decodeURIComponent(results[1].replace(/\ + /g, " "));
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
};
Share.prototype.removeVariableFromURL = function(url_string, variable_name) {
@@ -478,13 +663,6 @@ Annotator.Plugin.Share = (function(_super) {
regex = null;
return URL;
}
Share.prototype.getAnnotationFromId = function(ovaId) {
var annotationList = this.annotator.plugins.Store.annotations;
return $.grep(annotationList, function(elementOfArray, indexInArray){
return parseInt(elementOfArray.id, 10) === parseInt(ovaId.nodeValue, 10);
})[0];
}
Share.prototype.updateViewer = function(field, annotation) {
this.annotation = annotation;
@@ -492,15 +670,15 @@ Annotator.Plugin.Share = (function(_super) {
var self = this;
var field = $(field);
var ret = field.addClass('share-viewer-annotator').html(function() {
var string;
return self.buildHTMLShareButton('Share:', self.getSource('ovaId'));
});
// Create the actions for the buttons
this.buttonsActions(field[0], 1, this.options.baseUrl, annotation); // 1 is the method of the API that will be for share some annotation in the database
this.buttonsActions(field[0], 1, this.options.baseUrl); // 1 is the method of the API that will be for share some annotation in the database
return ret;
};
return Share;
})(Annotator.Plugin);

View File

@@ -6,9 +6,9 @@
<%block name="bodyclass">register verification-process step-select-track ${'is-upgrading' if upgrade else ''}</%block>
<%block name="pagetitle">
% if upgrade:
${_("Upgrade Your Registration for {} | Choose Your Track").format(course_name)}
${_("Upgrade Your Enrollment for {} | Choose Your Track").format(course_name)}
% else:
${_("Register for {} | Choose Your Track").format(course_name)}
${_("Enroll In {} | Choose Your Track").format(course_name)}
%endif
</%block>
@@ -51,7 +51,7 @@
<div class=" msg msg-error">
<i class="msg-icon icon-warning-sign"></i>
<div class="msg-content">
<h3 class="title">${_("Sorry, there was an error when trying to register you")}</h3>
<h3 class="title">${_("Sorry, there was an error when trying to enroll you")}</h3>
<div class="copy">
<p>${error}</p>
</div>
@@ -104,7 +104,7 @@
<ul class="list-actions">
<li class="action action-select">
% if upgrade:
<input type="submit" name="verified_mode" value="${_('Upgrade Your Registration')}" />
<input type="submit" name="verified_mode" value="${_('Upgrade Your Enrollment')}" />
% else:
<input type="submit" name="verified_mode" value="${_('Pursue a Verified Certificate')}" />
% endif

View File

@@ -2,6 +2,7 @@
Course Advanced Settings page
"""
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
from .utils import press_the_notification_button, type_in_codemirror, get_codemirror_value
@@ -13,6 +14,7 @@ MODAL_SELECTOR = ".validation-error-modal-content"
ERROR_ITEM_NAME_SELECTOR = ".error-item-title strong"
ERROR_ITEM_CONTENT_SELECTOR = ".error-item-message"
class AdvancedSettingsPage(CoursePage):
"""
Course Advanced Settings page.
@@ -21,6 +23,10 @@ class AdvancedSettingsPage(CoursePage):
url_path = "settings/advanced"
def is_browser_on_page(self):
def _is_finished_loading():
return len(self.q(css='.course-advanced-policy-list-item')) > 0
EmptyPromise(_is_finished_loading, 'Finished rendering the advanced policy items.').fulfill()
return self.q(css='body.advanced').present
def wait_for_modal_load(self):

View File

@@ -1,7 +1,7 @@
"""
Course Group Configurations page.
"""
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
from .utils import confirm_prompt
@@ -14,7 +14,20 @@ class GroupConfigurationsPage(CoursePage):
url_path = "group_configurations"
def is_browser_on_page(self):
return self.q(css='body.view-group-configurations').present
"""
Verify that the browser is on the page and it is not still loading.
"""
EmptyPromise(
lambda: self.q(css='body.view-group-configurations').present,
'On the group configuration page'
).fulfill()
EmptyPromise(
lambda: not self.q(css='span.spin').visible,
'Group Configurations are finished loading'
).fulfill()
return True
@property
def group_configurations(self):

View File

@@ -4,6 +4,7 @@ Utility methods useful for Studio page tests.
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.keys import Keys
from bok_choy.promise import EmptyPromise
from bok_choy.javascript import js_defined
from ...tests.helpers import disable_animations
@@ -50,6 +51,7 @@ def wait_for_notification(page):
EmptyPromise(_is_saving_done, 'Notification should have been hidden.', timeout=60).fulfill()
@js_defined('window.jQuery')
def press_the_notification_button(page, name):
# Because the notification uses a CSS transition,
# Selenium will always report it as being visible.
@@ -101,6 +103,7 @@ def add_advanced_component(page, menu_index, name):
click_css(page, component_css, 0)
@js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror;
@@ -110,6 +113,7 @@ def type_in_codemirror(page, index, text, find_prefix="$"):
page.browser.execute_script(script, str(text))
@js_defined('window.jQuery')
def get_codemirror_value(page, index=0, find_prefix="$"):
return page.browser.execute_script(
"""

Binary file not shown.

View File

@@ -38,7 +38,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2014-10-20 10:16-0400\n"
"POT-Creation-Date: 2014-10-27 10:50-0400\n"
"PO-Revision-Date: 2014-06-02 13:44+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: Amharic (http://www.transifex.com/projects/p/edx-platform/language/am/)\n"
@@ -1275,6 +1275,16 @@ msgstr ""
msgid "Whether to force the save button to appear on the page"
msgstr ""
#: common/lib/xmodule/xmodule/capa_base.py
msgid "Show Reset Button"
msgstr ""
#: common/lib/xmodule/xmodule/capa_base.py
msgid ""
"Determines whether a 'Reset' button is shown so the user may reset their "
"answer. A default value can be set in Advanced Settings."
msgstr ""
#: common/lib/xmodule/xmodule/capa_base.py
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid "Randomization"
@@ -2890,9 +2900,12 @@ msgstr ""
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid ""
"Enter the maximum number of times a student can try to answer problems. This"
" is a course-wide setting, but you can specify a different number when you "
"create an individual problem. To allow unlimited attempts, enter null."
"Enter the maximum number of times a student can try to answer problems. By "
"default, Maximum Attempts is set to null, meaning that students have an "
"unlimited number of attempts for problems. You can override this course-wide"
" setting for individual problems. However, if the course-wide setting is a "
"specific number, you cannot set the Maximum Attempts for individual problems"
" to unlimited."
msgstr ""
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
@@ -2928,6 +2941,17 @@ msgid ""
"Enter true or false. If true, video caching will be used for HTML5 videos."
msgstr ""
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid "Show Reset Button for Problems"
msgstr ""
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid ""
"Enter true or false. If true, problems default to displaying a 'Reset' "
"button. This value may be overriden in each problem's settings. Existing "
"problems whose reset setting have not been changed are affected."
msgstr ""
#. Translators: "Self" is used to denote an openended response that is self-
#. graded
#: common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -3404,6 +3428,9 @@ msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/metrics.html
#: lms/templates/instructor/instructor_dashboard_2/metrics.html
#: lms/templates/open_ended_problems/open_ended_flagged_problems.html
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/receipt.html
msgid "Name"
msgstr ""
@@ -3532,6 +3559,8 @@ msgid "Unable to switch to specified branch. Please check your branch name."
msgstr ""
#: lms/djangoapps/dashboard/support.py
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/billing_details.html
msgid "Email Address"
msgstr ""
@@ -3726,6 +3755,7 @@ msgstr ""
#: lms/djangoapps/dashboard/sysadmin.py lms/djangoapps/dashboard/sysadmin.py
#: cms/templates/course-create-rerun.html cms/templates/index.html
#: lms/templates/shoppingcart/receipt.html
msgid "Course Name"
msgstr ""
@@ -3847,6 +3877,34 @@ msgstr ""
msgid "Task is already running."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Could not read uploaded file."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"Data in row #{row_num} must have exactly four columns: email, username, full"
" name, and country"
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Invalid email {email_address}."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"An account with email {email} exists but the provided username {username} is"
" different. Enrolling anyway with {email}."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Username {user} already exists."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "File is not attached."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Invoice number '{0}' does not exist."
msgstr ""
@@ -4671,6 +4729,12 @@ msgstr ""
msgid "Order Payment Confirmation"
msgstr ""
#: lms/djangoapps/shoppingcart/models.py
msgid ""
"Confirmation and Registration Codes for the following courses: "
"{course_name_list}"
msgstr ""
#: lms/djangoapps/shoppingcart/models.py
msgid "Trying to add a different currency into the cart"
msgstr ""
@@ -4685,6 +4749,10 @@ msgid ""
"new course."
msgstr ""
#: lms/djangoapps/shoppingcart/models.py
msgid "Enrollment codes for Course: {course_name}"
msgstr ""
#: lms/djangoapps/shoppingcart/models.py
msgid "[Refund] User-Requested Refund"
msgstr ""
@@ -4781,8 +4849,6 @@ msgstr ""
#: lms/djangoapps/shoppingcart/reports.py
#: lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
msgid "Currency"
msgstr ""
@@ -4790,8 +4856,6 @@ msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
#: lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: wiki/plugins/attachments/forms.py
@@ -4923,6 +4987,10 @@ msgstr ""
msgid "Coupon '{0}' is not valid for any course in the shopping cart."
msgstr ""
#: lms/djangoapps/shoppingcart/views.py
msgid "success"
msgstr ""
#: lms/djangoapps/shoppingcart/views.py
msgid "You do not have permission to view this page."
msgstr ""
@@ -6683,18 +6751,14 @@ msgstr ""
msgid "Current Courses"
msgstr ""
#: lms/templates/dashboard.html
msgid "Looks like you haven't registered for any courses yet."
#: lms/templates/dashboard.html lms/templates/dashboard.html
msgid "Looks like you haven't enrolled in any courses yet."
msgstr ""
#: lms/templates/dashboard.html
msgid "Find courses now!"
msgstr ""
#: lms/templates/dashboard.html
msgid "Looks like you haven't been enrolled in any courses yet."
msgstr ""
#: lms/templates/dashboard.html
msgid "Course-loading errors"
msgstr ""
@@ -6787,7 +6851,8 @@ msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Unregister"
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Unenroll"
msgstr ""
#: lms/templates/edit_unit_link.html
@@ -6998,7 +7063,6 @@ msgstr ""
#: lms/templates/forgot_password_modal.html
#: lms/templates/forgot_password_modal.html
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
msgid "Password Reset"
msgstr ""
@@ -7697,6 +7761,7 @@ msgstr ""
#: lms/templates/register.html
#: lms/templates/instructor/instructor_dashboard_2/generate_registarion_codes_modal.html
#: lms/templates/shoppingcart/receipt.html
msgid "City"
msgstr ""
@@ -7706,6 +7771,7 @@ msgstr ""
#: lms/templates/register.html
#: lms/templates/instructor/instructor_dashboard_2/generate_registarion_codes_modal.html
#: lms/templates/shoppingcart/receipt.html
msgid "Country"
msgstr ""
@@ -8975,6 +9041,8 @@ msgid "Day"
msgstr ""
#: lms/templates/courseware/instructor_dashboard.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
msgid "Students"
msgstr ""
@@ -9091,16 +9159,16 @@ msgid "Access Courseware"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "You Are Registered"
msgid "You Are Enrolled"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Register for"
msgid "Enroll in"
msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Register for COURSE_NAME"
#. line is "Enroll in COURSE_NAME"
#. The "choose your student track" means users can select between taking the
#. course as an auditor, as a verified student, etc
#: lms/templates/courseware/mktg_course_about.html
@@ -9109,7 +9177,7 @@ msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Register for COURSE_NAME"
#. line is "Enroll in COURSE_NAME"
#. 'Verification' here refers to verifying one's identity in order to receive
#. a
#. verified certificate.
@@ -9118,7 +9186,7 @@ msgid "and proceed to verification"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Registration Is Closed"
msgid "Enrollment Is Closed"
msgstr ""
#: lms/templates/courseware/news.html
@@ -9338,7 +9406,7 @@ msgid ""
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "unregister"
msgid "unenroll"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
@@ -9360,12 +9428,12 @@ msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Are you sure you want to unregister from the purchased course"
msgid "Are you sure you want to unenroll from the purchased course"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Are you sure you want to unregister from"
msgid "Are you sure you want to unenroll from"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
@@ -9373,8 +9441,8 @@ msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid ""
"Are you sure you want to unregister from the verified {cert_name_long} track"
" of"
"Are you sure you want to unenroll from the verified {cert_name_long} track "
"of"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
@@ -9970,6 +10038,36 @@ msgstr ""
msgid "Active Threads"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "Welcome to {course_name}"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid ""
"To get started, please visit https://{site_name}. The login information for "
"your account follows."
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "email: {email}"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "password: {password}"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "It is recommended that you change your password."
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "Sincerely yours,"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "The {course_name} Team"
msgstr ""
#: lms/templates/emails/activation_email.txt
msgid "Thank you for signing up for {platform_name}."
msgstr ""
@@ -9992,6 +10090,8 @@ msgid "We hope you enjoy learning with {platform_name}!"
msgstr ""
#: lms/templates/emails/activation_email.txt
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "The {platform_name} Team"
msgstr ""
@@ -10059,6 +10159,131 @@ msgstr ""
msgid "You have been invited to a beta test for {course_name}"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Hi {name},"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Thank you for your purchase of "
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Your payment was successful."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid ""
"If you have billing questions, please read the FAQ ({faq_url}) or contact "
"{billing_email}."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "If you have billing questions, please contact {billing_email}."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"{order_placed_by} placed an order and mentioned your name as the "
"Organization contact."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"{order_placed_by} placed an order and mentioned your name as the additional "
"receipt recipient."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "The items in your order are:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "Quantity - Description - Price"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "Total billed to credit/debit card: {currency_symbol}{total_cost}"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Company Name:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Purchase Order Number:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Company Contact Name:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Company Contact Email:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Recipient Name:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Recipient Email:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "#:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Order Number: {order_number}"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"A CSV file of your registration URLs is attached. Please distribute "
"registration URLs to each student planning to enroll using the email "
"template below."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "Warm regards,"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(1) Register for an account at <a href='https://{site_name}' "
">https://{site_name}</a>."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(2) Once registered, copy the redeem URL and paste it in your web browser."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(3) On the enrollment confirmation page, Click the 'Activate Enrollment "
"Code' button. This will show the enrollment confirmation."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(4) You should be able to click on 'view course' button or see your course "
"on your student dashboard at <a "
"href='https://{dashboard_url}'>https://{dashboard_url}</a>"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(5) Course materials will not be available until the course start date."
msgstr ""
#: lms/templates/emails/confirm_email_change.txt
msgid ""
"This is to confirm that you changed the e-mail associated with "
@@ -10174,41 +10399,10 @@ msgid ""
"{merchant_name}."
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid ""
"If you have billing questions, please read the FAQ ({faq_url}) or contact "
"{billing_email}."
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid "If you have billing questions, please contact {billing_email}."
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid "-The {platform_name} Team"
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid "Your order number is: {order_number}"
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid "The items in your order are:"
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid "Quantity - Description - Price"
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
msgid "Total billed to credit/debit card: {currency_symbol}{total_cost}"
msgstr ""
#: lms/templates/emails/order_confirmation_email.txt
#: lms/templates/shoppingcart/receipt.html
msgid "#:"
msgstr ""
#: lms/templates/emails/registration_codes_sale_email.txt
msgid "Thank you for your purchase of {course_name}!"
msgstr ""
@@ -10479,6 +10673,7 @@ msgstr ""
msgid "Skip"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
@@ -10520,6 +10715,8 @@ msgid "Honor"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
msgid "Total"
msgstr ""
@@ -10742,7 +10939,11 @@ msgid "Click to generate a CSV file for all sales records in this course"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
msgid "Download All e-Commerce Sales"
msgid "Download All Invoice Sales"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
msgid "Download All Order Sales"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
@@ -10941,6 +11142,7 @@ msgid "What is the company the seats were sold to?"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/generate_registarion_codes_modal.html
#: lms/templates/shoppingcart/billing_details.html
msgid "Organization Contact"
msgstr ""
@@ -11112,7 +11314,19 @@ msgid "Enroll"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Unenroll"
msgid "Register/Enroll Students"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid ""
"To register and enroll a list of users in this course, choose a CSV file "
"that contains the following columns in this exact order: email, username, "
"name, and country. Please include one student per row and do not include any"
" headers, footers, or blank lines."
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Upload CSV"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
@@ -11735,6 +11949,59 @@ msgid ""
"address you submitted. You should be receiving it shortly."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Billing Details"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid ""
"You can proceed to payment at any point in time. Any additional information "
"you provide will be included in your receipt."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Purchasing Organizational Details"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Purchasing organization"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Purchase order number (if any)"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/billing_details.html
msgid "email@example.com"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Additional Receipt Recipient"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid ""
"If no additional billing details are populated the payment confirmation will"
" be sent to the user making the purchase"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Payment processing occurs on a separate secure site."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Your Shopping cart is currently empty."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "View Courses"
msgstr ""
#: lms/templates/shoppingcart/download_report.html
msgid "Download CSV Data"
msgstr ""
@@ -11774,68 +12041,149 @@ msgstr ""
msgid "There was an error processing your order!"
msgstr ""
#: lms/templates/shoppingcart/list.html
msgid "Your Shopping Cart"
msgstr ""
#: lms/templates/shoppingcart/list.html
msgid "Your selected items:"
msgstr ""
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
msgid "Price"
msgstr ""
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
msgid "Total Amount"
msgstr ""
#: lms/templates/shoppingcart/list.html
msgid "You have selected no items for purchase."
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Register for [Course Name] | Receipt (Order"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Thank you for your Purchase!"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "View Dashboard"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid ""
"Please print this receipt page for your records. You should also have "
"received a receipt in your email."
"You have successfully been enrolled for <b>{appended_course_names}</b>. The "
"following receipt has been emailed to "
"<strong>{appended_recipient_emails}</strong>"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{platform_name} ({site_name}) Electronic Receipt"
msgid ""
"You have successfully purchased <b>{total_registration_codes} course "
"registration codes</b> for <b>{appended_course_names}. </b>The following "
"receipt has been emailed to <strong>{appended_recipient_emails}</strong>"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Order #"
msgid ""
"Please send each professional one of these unique registration codes to "
"enroll into the course. The confirmation/receipt email you will receive has "
"an example email template with directions for the individuals enrolling."
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Date:"
msgid "Enrollment Code"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Items ordered:"
msgid "Enrollment Link"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Qty"
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "{course_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "URL"
msgid "Invoice"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Unit Price"
msgid "Date of purchase"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Print Receipt"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Billed To Details"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Company Name"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{company_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Purchase Order Number"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{customer_reference_number}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Company Contact Name"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{company_contact_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Company Contact Email"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Recipient Name"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{recipient_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Recipient Email"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Card Type"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Credit Card Number"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Address 1"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Address 2"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "State"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Registration for"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Course Dates"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid " {course_name} "
msgstr ""
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/shopping_cart.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Price per student:"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Discount Applied:"
msgstr ""
#. Translators: Please keep the "<del>" and "</del>" tags around your
@@ -11845,10 +12193,6 @@ msgstr ""
msgid "Note: items with strikethough like <del>this</del> have been refunded."
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Billed To:"
msgstr ""
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "Confirm Enrollment"
msgstr ""
@@ -11865,10 +12209,6 @@ msgstr ""
msgid "course dates"
msgstr ""
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "{course_name}"
msgstr ""
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "{start_date}"
msgstr ""
@@ -11907,6 +12247,72 @@ msgstr ""
msgid "View Course &nbsp; &nbsp; &#x25b8;"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Registration for:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Course Dates:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Students:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "code has been applied"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Total:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid ""
"After this purchase is complete, a receipt is generated with relative "
"billing details and registration codes for students."
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "After this purchase is complete,"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "will be enrolled in this course."
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
msgid "Shopping cart"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
msgid "{platform_name} - Shopping Cart"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Review"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
msgid "Payment"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Confirmation"
msgstr ""
#: lms/templates/shoppingcart/verified_cert_receipt.html
msgid "Receipt (Order"
msgstr ""
@@ -11960,15 +12366,6 @@ msgstr ""
msgid "Take ID Photo"
msgstr ""
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Review"
msgstr ""
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/show_requirements.html
@@ -11976,15 +12373,6 @@ msgstr ""
msgid "Make Payment"
msgstr ""
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Confirmation"
msgstr ""
#: lms/templates/shoppingcart/verified_cert_receipt.html
msgid "Congratulations! You are now verified on "
msgstr ""
@@ -12174,7 +12562,6 @@ msgstr ""
msgid "Student Account"
msgstr ""
#: lms/templates/student_profile/index.html
#: lms/templates/student_profile/index.html
msgid "Student Profile"
msgstr ""
@@ -14336,34 +14723,6 @@ msgid ""
"students' problem scores."
msgstr ""
#: cms/templates/import.html
msgid "There was an error during the upload process."
msgstr ""
#: cms/templates/import.html
msgid "There was an error while unpacking the file."
msgstr ""
#: cms/templates/import.html
msgid "There was an error while verifying the file you submitted."
msgstr ""
#: cms/templates/import.html
msgid "There was an error while importing the new course to our database."
msgstr ""
#: cms/templates/import.html
msgid "Your import has failed."
msgstr ""
#: cms/templates/import.html cms/templates/import.html
msgid "Choose new file"
msgstr ""
#: cms/templates/import.html
msgid "Your import is in progress; navigating away will abort it."
msgstr ""
#: cms/templates/index.html cms/templates/index.html
#: cms/templates/widgets/header.html
msgid "My Courses"
@@ -15070,14 +15429,6 @@ msgid ""
"new courses to take."
msgstr ""
#: cms/templates/settings_advanced.html
msgid "Hide Deprecated Settings"
msgstr ""
#: cms/templates/settings_advanced.html cms/templates/settings_advanced.html
msgid "Show Deprecated Settings"
msgstr ""
#: cms/templates/settings_advanced.html
msgid "Your policy changes have been saved."
msgstr ""
@@ -15096,6 +15447,10 @@ msgid ""
"familiar with their purpose."
msgstr ""
#: cms/templates/settings_advanced.html
msgid "Show Deprecated Settings"
msgstr ""
#: cms/templates/settings_advanced.html
msgid "What do advanced settings do?"
msgstr ""
@@ -15190,10 +15545,6 @@ msgstr ""
msgid "This block contains multiple components."
msgstr ""
#: cms/templates/textbooks.html
msgid "You have unsaved changes. Do you really want to leave this page?"
msgstr ""
#: cms/templates/textbooks.html
msgid "New Textbook"
msgstr ""

View File

@@ -26,7 +26,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2014-10-20 10:15-0400\n"
"POT-Creation-Date: 2014-10-27 10:49-0400\n"
"PO-Revision-Date: 2014-10-08 18:08+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: Amharic (http://www.transifex.com/projects/p/edx-platform/language/am/)\n"
@@ -39,7 +39,8 @@ msgstr ""
#. Translators: this is a message from the raw HTML editor displayed in the
#. browser when a user needs to edit HTML
#: cms/static/coffee/src/views/tabs.js
#: cms/static/coffee/src/views/tabs.js cms/static/js/factories/manage_users.js
#: cms/static/js/factories/manage_users.js
#: cms/static/js/views/course_info_update.js
#: cms/static/js/views/modals/edit_xblock.js
#: common/lib/xmodule/xmodule/js/src/html/edit.js
@@ -50,7 +51,8 @@ msgstr ""
#. #-#-#-#-# djangojs-partial.po (edx-platform) #-#-#-#-#
#. Translators: this is a message from the raw HTML editor displayed in the
#. browser when a user needs to edit HTML
#: cms/static/coffee/src/views/tabs.js cms/static/js/views/asset.js
#: cms/static/coffee/src/views/tabs.js cms/static/js/factories/export.js
#: cms/static/js/factories/manage_users.js cms/static/js/views/asset.js
#: cms/static/js/views/course_info_update.js
#: cms/static/js/views/show_textbook.js cms/static/js/views/validation.js
#: cms/static/js/views/modals/base_modal.js
@@ -75,7 +77,14 @@ msgstr ""
msgid "This link will open in a new browser window/tab"
msgstr ""
#: cms/static/js/views/asset.js
#: cms/static/js/factories/manage_users.js
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
msgid "Unknown"
msgstr ""
#: cms/static/js/factories/manage_users.js cms/static/js/views/asset.js
#: cms/static/js/views/group_configuration_item.js
#: cms/static/js/views/show_textbook.js
#: common/static/js/vendor/ova/catch/js/catch.js
@@ -2391,6 +2400,18 @@ msgstr ""
msgid "Error: You cannot remove yourself from the Instructor group!"
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "The following errors were generated:"
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "The following warnings were generated:"
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "All accounts were created successfully."
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "Error adding/removing users as beta testers."
msgstr ""
@@ -2715,12 +2736,6 @@ msgid ""
" technical support if the problem persists."
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
msgid "Unknown"
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/util.js
msgid "Subject"
msgstr ""
@@ -2994,6 +3009,16 @@ msgstr ""
msgid "Please enter a valid password"
msgstr ""
#: lms/static/js/student_account/account.js
msgid ""
"Password reset email sent. Follow the link in the email to change your "
"password."
msgstr ""
#: lms/static/js/student_account/account.js
msgid "We weren't able to send you a password reset email."
msgstr ""
#: lms/static/js/student_account/account.js
msgid "Please check your email to confirm the change"
msgstr ""
@@ -3165,6 +3190,139 @@ msgstr ""
msgid "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
msgstr ""
#: cms/static/js/factories/export.js
msgid "There has been an error while exporting."
msgstr ""
#: cms/static/js/factories/export.js
msgid ""
"There has been a failure to export to XML at least one component. It is "
"recommended that you go to the edit page and repair the error before "
"attempting another export. Please check that all components on the page are "
"valid and do not display any error messages."
msgstr ""
#: cms/static/js/factories/export.js
msgid "Correct failed component"
msgstr ""
#: cms/static/js/factories/export.js
msgid "Return to Export"
msgstr ""
#: cms/static/js/factories/export.js
msgid ""
"There has been a failure to export your course to XML. Unfortunately, we do "
"not have specific enough information to assist you in identifying the failed"
" component. It is recommended that you inspect your courseware to identify "
"any components in error and try again."
msgstr ""
#: cms/static/js/factories/export.js
msgid "The raw error message is:"
msgstr ""
#: cms/static/js/factories/export.js
msgid "There has been an error with your export."
msgstr ""
#: cms/static/js/factories/export.js
msgid "Yes, take me to the main course page"
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error during the upload process."
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error while unpacking the file."
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error while verifying the file you submitted."
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error while importing the new course to our database."
msgstr ""
#: cms/static/js/factories/import.js
msgid "Your import has failed."
msgstr ""
#: cms/static/js/factories/import.js cms/static/js/factories/import.js
#: cms/static/js/views/import.js cms/static/js/views/import.js.c
msgid "Choose new file"
msgstr ""
#: cms/static/js/factories/import.js
msgid "Your import is in progress; navigating away will abort it."
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "A valid email address is required"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "You must enter a valid email address in order to add a new team member"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Return and add email address"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Already a course team member"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid ""
"{email} is already on the “{course}” team. If you're trying to add a new "
"member, please double-check the email address you provided."
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Return to team listing"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Error adding user"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Are you sure?"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid ""
"Are you sure you want to delete {email} from the course team for “{course}”?"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Error removing user"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "There was an error changing the user's role"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Try Again"
msgstr ""
#: cms/static/js/factories/settings_advanced.js
msgid "Hide Deprecated Settings"
msgstr ""
#: cms/static/js/factories/settings_advanced.js
msgid "Show Deprecated Settings"
msgstr ""
#: cms/static/js/factories/textbooks.js
#: cms/static/js/views/pages/group_configurations.js
msgid "You have unsaved changes. Do you really want to leave this page?"
msgstr ""
#: cms/static/js/models/course.js cms/static/js/models/section.js
msgid "You must specify a name"
msgstr ""
@@ -3363,10 +3521,6 @@ msgstr ""
msgid "Deleting"
msgstr ""
#: cms/static/js/views/import.js cms/static/js/views/import.js.c
msgid "Choose new file"
msgstr ""
#: cms/static/js/views/import.js
msgid "Error importing course"
msgstr ""
@@ -3591,10 +3745,6 @@ msgstr ""
msgid "Making Visible to Students&hellip;"
msgstr ""
#: cms/static/js/views/pages/group_configurations.js
msgid "You have unsaved changes. Do you really want to leave this page?"
msgstr ""
#: cms/static/js/views/settings/advanced.js
msgid ""
"Your changes will not take effect until you save your progress. Take care "
@@ -3834,6 +3984,10 @@ msgstr ""
msgid "Change My Email Address"
msgstr ""
#: lms/templates/student_account/account.underscore
msgid "Reset Password"
msgstr ""
#: lms/templates/student_profile/profile.underscore
msgid "Full Name"
msgstr ""

Binary file not shown.

View File

@@ -63,6 +63,7 @@
# Mahmoud Elkhateeb <eng.elkhteeb@gmail.com>, 2013
# Mahmoud Elkhateeb <eng.elkhteeb@gmail.com>, 2013
# may <may@qordoba.com>, 2014
# mustafasadiq <mustafasadiq0@gmail.com>, 2014
# Nabeel El-Dughailib <nabeel@qordoba.com>, 2014
# Najwan Al Rousan <najwanrousan@gmail.com>, 2013-2014
# Najwan Al Rousan <najwanrousan@gmail.com>, 2013
@@ -113,7 +114,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2014-10-20 10:16-0400\n"
"POT-Creation-Date: 2014-10-27 10:50-0400\n"
"PO-Revision-Date: 2014-09-02 00:40+0000\n"
"Last-Translator: Nabeel El-Dughailib <nabeel@qordoba.com>\n"
"Language-Team: Arabic (http://www.transifex.com/projects/p/edx-platform/language/ar/)\n"
@@ -1443,6 +1444,16 @@ msgstr "أبداً"
msgid "Whether to force the save button to appear on the page"
msgstr "اختيار فرض إظهار زرّ الحفظ على الصفحة "
#: common/lib/xmodule/xmodule/capa_base.py
msgid "Show Reset Button"
msgstr ""
#: common/lib/xmodule/xmodule/capa_base.py
msgid ""
"Determines whether a 'Reset' button is shown so the user may reset their "
"answer. A default value can be set in Advanced Settings."
msgstr ""
#: common/lib/xmodule/xmodule/capa_base.py
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid "Randomization"
@@ -3294,13 +3305,13 @@ msgstr ""
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid ""
"Enter the maximum number of times a student can try to answer problems. This"
" is a course-wide setting, but you can specify a different number when you "
"create an individual problem. To allow unlimited attempts, enter null."
"Enter the maximum number of times a student can try to answer problems. By "
"default, Maximum Attempts is set to null, meaning that students have an "
"unlimited number of attempts for problems. You can override this course-wide"
" setting for individual problems. However, if the course-wide setting is a "
"specific number, you cannot set the Maximum Attempts for individual problems"
" to unlimited."
msgstr ""
"إدخال الحدّ الأقصى لعدد المرّات التي يسمح فيها للطالب بمحاولة الإجابة على "
"المسائل. إنّه إعداد على نطاق المساق، ولكن يمكنك تحديد رقم مختلف عندما تقوم "
"بإنشاء مسألة فردية. للسماح بعدد محاولات غير محدّد، الرجاء إدخال \"لا يوجد\"."
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid "Matlab API key"
@@ -3345,6 +3356,17 @@ msgstr ""
"الرجاء إدخال صواب أو خطأ، في حال الصواب سيتمّ اعتماد التخزين المؤثت لمقاطع "
"الفيديو بلغة HTML5."
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid "Show Reset Button for Problems"
msgstr ""
#: common/lib/xmodule/xmodule/modulestore/inheritance.py
msgid ""
"Enter true or false. If true, problems default to displaying a 'Reset' "
"button. This value may be overriden in each problem's settings. Existing "
"problems whose reset setting have not been changed are affected."
msgstr ""
#. Translators: "Self" is used to denote an openended response that is self-
#. graded
#: common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -3883,6 +3905,9 @@ msgstr "حقوق الطبع والنشر "
#: lms/templates/instructor/instructor_dashboard_2/metrics.html
#: lms/templates/instructor/instructor_dashboard_2/metrics.html
#: lms/templates/open_ended_problems/open_ended_flagged_problems.html
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/receipt.html
msgid "Name"
msgstr "الاسم "
@@ -4225,14 +4250,8 @@ msgid "Loaded course {course_name}<br/>Errors:"
msgstr "المساق الذي تمّ تحميله {course_name} <br/> الأخطاء:"
#: lms/djangoapps/dashboard/sysadmin.py lms/djangoapps/dashboard/sysadmin.py
#: cms/templates/course-create-rerun.html cms/templates/index.html
#, fuzzy
msgid "Course Name"
msgstr ""
"#-#-#-#-# django-partial.po (edx-platform) #-#-#-#-#\n"
"اسم المساق \n"
"#-#-#-#-# mako-studio.po (edx-platform) #-#-#-#-#\n"
"اسم المساق"
msgstr "اسم المساق "
#: lms/djangoapps/dashboard/sysadmin.py
msgid "Directory/ID"
@@ -4355,6 +4374,34 @@ msgstr "المستخدم غير موجود."
msgid "Task is already running."
msgstr "المهمة قيد التشغيل بالفعل."
#: lms/djangoapps/instructor/views/api.py
msgid "Could not read uploaded file."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"Data in row #{row_num} must have exactly four columns: email, username, full"
" name, and country"
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Invalid email {email_address}."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid ""
"An account with email {email} exists but the provided username {username} is"
" different. Enrolling anyway with {email}."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Username {user} already exists."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "File is not attached."
msgstr ""
#: lms/djangoapps/instructor/views/api.py
msgid "Invoice number '{0}' does not exist."
msgstr "رقم الفاتورة '{0}' غير موجود."
@@ -5289,6 +5336,12 @@ msgstr ""
msgid "Order Payment Confirmation"
msgstr "تأكيد الدفع لطلب الشراء"
#: lms/djangoapps/shoppingcart/models.py
msgid ""
"Confirmation and Registration Codes for the following courses: "
"{course_name_list}"
msgstr ""
#: lms/djangoapps/shoppingcart/models.py
msgid "Trying to add a different currency into the cart"
msgstr "محاولة إضافة عملة مختلفة لسلة التسوق"
@@ -5305,6 +5358,10 @@ msgstr ""
"يرجى زيارة <a href=\"{dashboard_link}\">لوحة المعلومات الخاصة بك</a> لمشاهدة"
" مساقك الجديد."
#: lms/djangoapps/shoppingcart/models.py
msgid "Enrollment codes for Course: {course_name}"
msgstr ""
#: lms/djangoapps/shoppingcart/models.py
msgid "[Refund] User-Requested Refund"
msgstr "[Refund] مبلغ الاسترداد الذي طلبه المستخدم "
@@ -5413,8 +5470,6 @@ msgstr "التكلفة الإجمالية"
#: lms/djangoapps/shoppingcart/reports.py
#: lms/templates/instructor/instructor_dashboard_2/set_course_mode_price_modal.html
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
msgid "Currency"
msgstr "العملة "
@@ -5422,8 +5477,6 @@ msgstr "العملة "
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
#: lms/templates/instructor/instructor_dashboard_2/edit_coupon_modal.html
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: wiki/plugins/attachments/forms.py
@@ -5560,6 +5613,10 @@ msgstr "يُسمح باسترداد قسيمة واحدة فقط للطلب ال
msgid "Coupon '{0}' is not valid for any course in the shopping cart."
msgstr "ليست القسيمة '{0}' صالحة لإي مساق في عربة التسوّق."
#: lms/djangoapps/shoppingcart/views.py
msgid "success"
msgstr ""
#: lms/djangoapps/shoppingcart/views.py
msgid "You do not have permission to view this page."
msgstr "ليست لديك الصلاحيات اللازمة للاطلاع على مكونات هذه الصفحة."
@@ -7580,18 +7637,14 @@ msgstr "إعادة ضبط كلمة السر"
msgid "Current Courses"
msgstr "المساقات الحالية"
#: lms/templates/dashboard.html
msgid "Looks like you haven't registered for any courses yet."
msgstr "يبدو أنك لم تقم بالتسجيل في أي من المساقات حتى الآن."
#: lms/templates/dashboard.html lms/templates/dashboard.html
msgid "Looks like you haven't enrolled in any courses yet."
msgstr ""
#: lms/templates/dashboard.html
msgid "Find courses now!"
msgstr "قم بإيجاد المساقات الآن!"
#: lms/templates/dashboard.html
msgid "Looks like you haven't been enrolled in any courses yet."
msgstr "يبدو أنك لم تقم بالتسجيل في أيٍ من المساقات حتى الآن. "
#: lms/templates/dashboard.html
msgid "Course-loading errors"
msgstr "حدوث أخطاء في تحميل المساق "
@@ -7694,8 +7747,9 @@ msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Unregister"
msgstr "إلغاء التسجيل"
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Unenroll"
msgstr "إلغاء التسجيل "
#: lms/templates/edit_unit_link.html
msgid "View Unit in Studio"
@@ -7819,7 +7873,7 @@ msgstr ""
"الأحياء والأعمال والكيمياء وعلوم الكومبيوتر والاقتصاد والتمويل والإلكترونيات"
" والهندسة وعلوم التغذية والتاريخ والعلوم الإنسانية والحقوق والآداب "
"والرياضيات والطب والموسيقى والفلسفة والفيزياء والعلوم والإحصاء وغيرها. {EdX}"
" هي مبادرة إلكترونية لاربحية أسسها الشركاء {HarvardX} و {MITx}."
" هي مبادرة إلكترونية لاربحية أسسها الشركاء {Harvard} و {MIT}."
#: lms/templates/footer-edx-new.html lms/templates/footer-legacy.html
msgid "&copy; 2014 edX, some rights reserved."
@@ -7920,7 +7974,6 @@ msgstr "&copy; 2014 {platform_name}، بعض الحقوق محفوظة."
#: lms/templates/forgot_password_modal.html
#: lms/templates/forgot_password_modal.html
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
msgid "Password Reset"
msgstr "إعادة ضبط كلمة السر"
@@ -8682,6 +8735,7 @@ msgstr "معلومات شخصية إضافية"
#: lms/templates/register.html
#: lms/templates/instructor/instructor_dashboard_2/generate_registarion_codes_modal.html
#: lms/templates/shoppingcart/receipt.html
msgid "City"
msgstr "المدينة"
@@ -8691,6 +8745,7 @@ msgstr "مثال: نيويورك"
#: lms/templates/register.html
#: lms/templates/instructor/instructor_dashboard_2/generate_registarion_codes_modal.html
#: lms/templates/shoppingcart/receipt.html
msgid "Country"
msgstr "الدولة"
@@ -10059,6 +10114,8 @@ msgid "Day"
msgstr "اليوم "
#: lms/templates/courseware/instructor_dashboard.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
msgid "Students"
msgstr "الطلاب "
@@ -10179,16 +10236,16 @@ msgid "Access Courseware"
msgstr "الدخول لمنهاج المساق "
#: lms/templates/courseware/mktg_course_about.html
msgid "You Are Registered"
msgstr "أنت مسجَل "
msgid "You Are Enrolled"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Register for"
msgstr "قم بالتسجيل في "
msgid "Enroll in"
msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Register for COURSE_NAME"
#. line is "Enroll in COURSE_NAME"
#. The "choose your student track" means users can select between taking the
#. course as an auditor, as a verified student, etc
#: lms/templates/courseware/mktg_course_about.html
@@ -10197,7 +10254,7 @@ msgstr "واختر مساقك كطالب"
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Register for COURSE_NAME"
#. line is "Enroll in COURSE_NAME"
#. 'Verification' here refers to verifying one's identity in order to receive
#. a
#. verified certificate.
@@ -10206,8 +10263,8 @@ msgid "and proceed to verification"
msgstr "وتابع لتستكمل التحقق"
#: lms/templates/courseware/mktg_course_about.html
msgid "Registration Is Closed"
msgstr "التسجيل مغلق "
msgid "Enrollment Is Closed"
msgstr ""
#: lms/templates/courseware/news.html
msgid "News - MITx 6.002x"
@@ -10451,8 +10508,8 @@ msgstr ""
"href=\"#\">التواصل مع صاحب الحساب </a>لطلب دفعة، أو يمكنك"
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "unregister"
msgstr "إلغاء التسجيل"
msgid "unenroll"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "for this course."
@@ -10473,24 +10530,22 @@ msgstr "استعراض المساق "
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Are you sure you want to unregister from the purchased course"
msgstr "هل أنت متأكد من رغبتك بإلغاء تسجيلك من المساق الذي اشتركت فيه"
msgid "Are you sure you want to unenroll from the purchased course"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Are you sure you want to unregister from"
msgstr "هل أنت واثقٌ من رغبتك إلغاء تسجيلك بـ"
msgid "Are you sure you want to unenroll from"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid ""
"Are you sure you want to unregister from the verified {cert_name_long} track"
" of"
"Are you sure you want to unenroll from the verified {cert_name_long} track "
"of"
msgstr ""
"هل أنت واثقٌ من رغبتك إلغاء تسجيلك من مسار الشهادات الموثّقة "
"{cert_name_long} لـ"
#: lms/templates/dashboard/_dashboard_course_listing.html
#: lms/templates/dashboard/_dashboard_course_listing.html
@@ -11115,6 +11170,36 @@ msgstr "عرض النقاش"
msgid "Active Threads"
msgstr "المواضيع الفعالة "
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "Welcome to {course_name}"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid ""
"To get started, please visit https://{site_name}. The login information for "
"your account follows."
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "email: {email}"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "password: {password}"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "It is recommended that you change your password."
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "Sincerely yours,"
msgstr ""
#: lms/templates/emails/account_creation_and_enroll_emailMessage.txt
msgid "The {course_name} Team"
msgstr ""
#: lms/templates/emails/activation_email.txt
msgid "Thank you for signing up for {platform_name}."
msgstr "شكرًا لتسجيلك في {platform_name}."
@@ -11143,6 +11228,8 @@ msgid "We hope you enjoy learning with {platform_name}!"
msgstr "نتمنى لك الاستمتاع بالتعلّم مع {platform_name}!"
#: lms/templates/emails/activation_email.txt
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "The {platform_name} Team"
msgstr "فريق {platform_name}"
@@ -11226,6 +11313,137 @@ msgstr ""
msgid "You have been invited to a beta test for {course_name}"
msgstr "لقد تمت دعوتك لإجراء اختبار بيتا للمساق {course_name}"
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Hi {name},"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Thank you for your purchase of "
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Your payment was successful."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid ""
"If you have billing questions, please read the FAQ ({faq_url}) or contact "
"{billing_email}."
msgstr ""
"في حال كان لديك أية استفسارات تتعلق بالفواتير والدفعات، الرجاء قراءة قسم "
"الأسئلة الأكثر تكراراً على الرابط ({faq_url}) أو الإتصال ب {billing_email}"
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "If you have billing questions, please contact {billing_email}."
msgstr ""
"في حال كان لديك أية استفسارات تتعلق بالفواتير والدفعات، الرجاء الإتصال ب "
"{billing_email}."
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"{order_placed_by} placed an order and mentioned your name as the "
"Organization contact."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"{order_placed_by} placed an order and mentioned your name as the additional "
"receipt recipient."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "The items in your order are:"
msgstr "العناصر التي يشتمل عليها طلبك:"
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "Quantity - Description - Price"
msgstr "الكمية - الوصف - السعر"
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "Total billed to credit/debit card: {currency_symbol}{total_cost}"
msgstr ""
"المجموع الذي تم اقتطاعه من بطاقة الائتمان/الخصم المباشر: "
"{currency_symbol}{total_cost}"
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Company Name:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Purchase Order Number:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Company Contact Name:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Company Contact Email:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Recipient Name:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Recipient Email:"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "#:"
msgstr "#: "
#: lms/templates/emails/business_order_confirmation_email.txt
msgid "Order Number: {order_number}"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"A CSV file of your registration URLs is attached. Please distribute "
"registration URLs to each student planning to enroll using the email "
"template below."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
#: lms/templates/emails/order_confirmation_email.txt
msgid "Warm regards,"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(1) Register for an account at <a href='https://{site_name}' "
">https://{site_name}</a>."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(2) Once registered, copy the redeem URL and paste it in your web browser."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(3) On the enrollment confirmation page, Click the 'Activate Enrollment "
"Code' button. This will show the enrollment confirmation."
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(4) You should be able to click on 'view course' button or see your course "
"on your student dashboard at <a "
"href='https://{dashboard_url}'>https://{dashboard_url}</a>"
msgstr ""
#: lms/templates/emails/business_order_confirmation_email.txt
msgid ""
"(5) Course materials will not be available until the course start date."
msgstr ""
#: lms/templates/emails/confirm_email_change.txt
msgid ""
"This is to confirm that you changed the e-mail associated with "
@@ -11374,47 +11592,10 @@ msgid ""
msgstr ""
"ستظهر الرسوم المسددة على البيان الخاص بك تحت اسم الشركة {merchant_name}."
#: lms/templates/emails/order_confirmation_email.txt
msgid ""
"If you have billing questions, please read the FAQ ({faq_url}) or contact "
"{billing_email}."
msgstr ""
"في حال كان لديك أية استفسارات تتعلق بالفواتير والدفعات، الرجاء قراءة قسم "
"الأسئلة الأكثر تكراراً على الرابط ({faq_url}) أو الإتصال ب {billing_email}"
#: lms/templates/emails/order_confirmation_email.txt
msgid "If you have billing questions, please contact {billing_email}."
msgstr ""
"في حال كان لديك أية استفسارات تتعلق بالفواتير والدفعات، الرجاء الإتصال ب "
"{billing_email}."
#: lms/templates/emails/order_confirmation_email.txt
msgid "-The {platform_name} Team"
msgstr "فريق {platform_name} "
#: lms/templates/emails/order_confirmation_email.txt
msgid "Your order number is: {order_number}"
msgstr "رقم طلبك هو : {order_number}"
#: lms/templates/emails/order_confirmation_email.txt
msgid "The items in your order are:"
msgstr "العناصر التي يشتمل عليها طلبك:"
#: lms/templates/emails/order_confirmation_email.txt
msgid "Quantity - Description - Price"
msgstr "الكمية - الوصف - السعر"
#: lms/templates/emails/order_confirmation_email.txt
msgid "Total billed to credit/debit card: {currency_symbol}{total_cost}"
msgstr ""
"المجموع الذي تم اقتطاعه من بطاقة الائتمان/الخصم المباشر: "
"{currency_symbol}{total_cost}"
#: lms/templates/emails/order_confirmation_email.txt
#: lms/templates/shoppingcart/receipt.html
msgid "#:"
msgstr "#: "
#: lms/templates/emails/registration_codes_sale_email.txt
msgid "Thank you for your purchase of {course_name}!"
msgstr "شكرًا لشرائك {course_name}!"
@@ -11730,6 +11911,7 @@ msgstr "إعطاء علامة تدل على محتوى غير مناسب لتت
msgid "Skip"
msgstr "تخطي "
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/add_coupon_modal.html
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
@@ -11771,6 +11953,8 @@ msgid "Honor"
msgstr "منح"
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
msgid "Total"
msgstr "المجموع "
@@ -12027,8 +12211,12 @@ msgstr ""
"انقر هنا لإنشاء ملف بصيغة CSV يحتوي على جميع سجلات المبيعات في هذا المساق"
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
msgid "Download All e-Commerce Sales"
msgstr "تنزيل جميع عمليات مبيعات مساق التجارة الإلكترونية"
msgid "Download All Invoice Sales"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
msgid "Download All Order Sales"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/e-commerce.html
msgid "Enter the invoice number to invalidate or re-validate sale"
@@ -12238,6 +12426,7 @@ msgid "What is the company the seats were sold to?"
msgstr "ما هي الشركة التي بيعَت لها المقاعد؟"
#: lms/templates/instructor/instructor_dashboard_2/generate_registarion_codes_modal.html
#: lms/templates/shoppingcart/billing_details.html
msgid "Organization Contact"
msgstr "موظف الاتصال في المنظمة"
@@ -12429,8 +12618,20 @@ msgid "Enroll"
msgstr "تسجيل"
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Unenroll"
msgstr "إلغاء التسجيل "
msgid "Register/Enroll Students"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid ""
"To register and enroll a list of users in this course, choose a CSV file "
"that contains the following columns in this exact order: email, username, "
"name, and country. Please include one student per row and do not include any"
" headers, footers, or blank lines."
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Upload CSV"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Batch Beta Tester Addition"
@@ -13134,6 +13335,59 @@ msgstr ""
"لقد قمنا بإرسال رسالةٍ على عنوان البريد الإلكتروني الذي زودتنا به تتضمن "
"التعليمات الخاصة بضبط كلمة السر، وستصلك قريباً. "
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Billing Details"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid ""
"You can proceed to payment at any point in time. Any additional information "
"you provide will be included in your receipt."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Purchasing Organizational Details"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Purchasing organization"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Purchase order number (if any)"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/billing_details.html
msgid "email@example.com"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Additional Receipt Recipient"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid ""
"If no additional billing details are populated the payment confirmation will"
" be sent to the user making the purchase"
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
msgid "Payment processing occurs on a separate secure site."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Your Shopping cart is currently empty."
msgstr ""
#: lms/templates/shoppingcart/billing_details.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "View Courses"
msgstr ""
#: lms/templates/shoppingcart/download_report.html
msgid "Download CSV Data"
msgstr "تنزيل البيانات بصيغة CSV"
@@ -13176,71 +13430,150 @@ msgstr "حدث خطأ في عملية الدفع "
msgid "There was an error processing your order!"
msgstr "لقد حدث خطأ لدى إجراء العملية الخاصة طلبك! "
#: lms/templates/shoppingcart/list.html
msgid "Your Shopping Cart"
msgstr "سلة التسوق الخاصة بك "
#: lms/templates/shoppingcart/list.html
msgid "Your selected items:"
msgstr "العناصر المختارة من قبلك: "
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
msgid "Price"
msgstr "السعر "
#: lms/templates/shoppingcart/list.html
#: lms/templates/shoppingcart/receipt.html
msgid "Total Amount"
msgstr "المبلغ الإجمالي "
#: lms/templates/shoppingcart/list.html
msgid "You have selected no items for purchase."
msgstr "لم تقم باختيار أي عنصر للشراء. "
#: lms/templates/shoppingcart/receipt.html
msgid "Register for [Course Name] | Receipt (Order"
msgstr "قم بالتسجيل في [اسم المساق] | إيصال (الطلب"
#: lms/templates/shoppingcart/receipt.html
msgid "Thank you for your Purchase!"
msgstr "شكرا لك على الشراء"
#: lms/templates/shoppingcart/receipt.html
msgid ""
"Please print this receipt page for your records. You should also have "
"received a receipt in your email."
msgid "View Dashboard"
msgstr ""
"الرجاء طباعة صفحة الإيصال هذه للاحتفاظ بها في سجلاتك. يجب أن تكون قد استلمت "
"أيضاً رسالة إلكترونية تحتوي على إيصال."
#: lms/templates/shoppingcart/receipt.html
msgid "{platform_name} ({site_name}) Electronic Receipt"
msgstr "إيصال إلكتروني من {platform_name} ({site_name})"
msgid ""
"You have successfully been enrolled for <b>{appended_course_names}</b>. The "
"following receipt has been emailed to "
"<strong>{appended_recipient_emails}</strong>"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Order #"
msgstr "طلبية # "
msgid ""
"You have successfully purchased <b>{total_registration_codes} course "
"registration codes</b> for <b>{appended_course_names}. </b>The following "
"receipt has been emailed to <strong>{appended_recipient_emails}</strong>"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Date:"
msgstr "التاريخ: "
msgid ""
"Please send each professional one of these unique registration codes to "
"enroll into the course. The confirmation/receipt email you will receive has "
"an example email template with directions for the individuals enrolling."
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Items ordered:"
msgstr "العناصر المطلوبة: "
msgid "Enrollment Code"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Qty"
msgstr "الكمية "
msgid "Enrollment Link"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "URL"
msgstr "الرابط"
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "{course_name}"
msgstr "{course_name}"
#: lms/templates/shoppingcart/receipt.html
msgid "Unit Price"
msgstr "سعر الوحدة "
msgid "Invoice"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Date of purchase"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Print Receipt"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Billed To Details"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Company Name"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{company_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Purchase Order Number"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{customer_reference_number}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Company Contact Name"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{company_contact_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Company Contact Email"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Recipient Name"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "{recipient_name}"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Recipient Email"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Card Type"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Credit Card Number"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Address 1"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Address 2"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "State"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Registration for"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid "Course Dates"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
msgid " {course_name} "
msgstr ""
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/shopping_cart.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Price per student:"
msgstr ""
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/receipt.html
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Discount Applied:"
msgstr ""
#. Translators: Please keep the "<del>" and "</del>" tags around your
#. translation of the word "this" in your translation.
@@ -13249,10 +13582,6 @@ msgstr "سعر الوحدة "
msgid "Note: items with strikethough like <del>this</del> have been refunded."
msgstr "ملاحظة: تمّ رد قيمة العناصر المشطوبة على النحو <del>هذا</del>."
#: lms/templates/shoppingcart/receipt.html
msgid "Billed To:"
msgstr "تم تسجيل الفاتورة لـ: "
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "Confirm Enrollment"
msgstr "تأكيد التسجيل"
@@ -13269,10 +13598,6 @@ msgstr "يرجى تأكيد تسجيلك في:"
msgid "course dates"
msgstr "تواريخ المساق"
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "{course_name}"
msgstr "{course_name}"
#: lms/templates/shoppingcart/registration_code_receipt.html
msgid "{start_date}"
msgstr "{start_date}"
@@ -13321,6 +13646,72 @@ msgstr ""
msgid "View Course &nbsp; &nbsp; &#x25b8;"
msgstr "عرض المساق &nbsp; &nbsp; &#x25b8;"
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Registration for:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Course Dates:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Students:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "code has been applied"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "Total:"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid ""
"After this purchase is complete, a receipt is generated with relative "
"billing details and registration codes for students."
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "After this purchase is complete,"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart.html
msgid "will be enrolled in this course."
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
msgid "Shopping cart"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
msgid "{platform_name} - Shopping Cart"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Review"
msgstr "مراجعة "
#: lms/templates/shoppingcart/shopping_cart_flow.html
msgid "Payment"
msgstr ""
#: lms/templates/shoppingcart/shopping_cart_flow.html
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Confirmation"
msgstr "تأكيد "
#: lms/templates/shoppingcart/verified_cert_receipt.html
msgid "Receipt (Order"
msgstr "الإيصال (الطلب"
@@ -13374,15 +13765,6 @@ msgstr "قم بأخذ صورة "
msgid "Take ID Photo"
msgstr "قم بأخذ صورة بطاقة شخصية "
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Review"
msgstr "مراجعة "
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/show_requirements.html
@@ -13390,15 +13772,6 @@ msgstr "مراجعة "
msgid "Make Payment"
msgstr "القيام بالدفع"
#: lms/templates/shoppingcart/verified_cert_receipt.html
#: lms/templates/verify_student/photo_reverification.html
#: lms/templates/verify_student/photo_verification.html
#: lms/templates/verify_student/reverification_confirmation.html
#: lms/templates/verify_student/show_requirements.html
#: lms/templates/verify_student/verified.html
msgid "Confirmation"
msgstr "تأكيد "
#: lms/templates/shoppingcart/verified_cert_receipt.html
msgid "Congratulations! You are now verified on "
msgstr "مبروك! تم التحقق من هويتك على "
@@ -13483,9 +13856,9 @@ msgid ""
"{link_start}homepage{link_end} or let us know about any pages that may have "
"been moved at {email}."
msgstr ""
"لم يتم إيجاد الصفحة التي كنت تبحث عنها، الرجاء العودة إلى {link_start}الصفحة"
" الرئيسية{link_end}، أو إعلامنا عبر البريد الإلكتروني {email} عن أية صفحات "
"يحتمل يكون قد تغير موقعها."
"عذراً، الصفحة التي تبحث عنها غير متوفرة. الرجاء إعلامنا عبر البريد "
"الإلكتروني {email} إن كنت تعتقد انها مشكلة من العنوان المستخدم. يمكنك العودة"
" الى {link_start}الصفحة الرئيسية{link_end}."
#: lms/templates/static_templates/about.html
#: lms/templates/static_templates/blog.html
@@ -13615,7 +13988,6 @@ msgstr ""
msgid "Student Account"
msgstr "حساب الطالب"
#: lms/templates/student_profile/index.html
#: lms/templates/student_profile/index.html
msgid "Student Profile"
msgstr "صفحة الطالب"
@@ -16049,35 +16421,6 @@ msgstr ""
"url_name) مكوِّن للمسائل، قد يتمّ فقدان بيانات الطالب المرتبطة بمكوِّنات "
"المسائل هذه. وتتضمّن هذه البيانات المسائل غير المقيّمة الخاصة بالطالب."
#: cms/templates/import.html
msgid "There was an error during the upload process."
msgstr "حدث خطأ أثناء عملية التحميل. "
#: cms/templates/import.html
msgid "There was an error while unpacking the file."
msgstr "حدث خطأ أثناء عملية تفريغ الملف. "
#: cms/templates/import.html
msgid "There was an error while verifying the file you submitted."
msgstr "حدث خطأ أثناء عملية التحقق من الملف الذي قمت بتقديمه. "
#: cms/templates/import.html
msgid "There was an error while importing the new course to our database."
msgstr "حدث خطأ خلال عملية استيراد المساق الجديد إلى قاعدة البيانات لدينا. "
#: cms/templates/import.html
msgid "Your import has failed."
msgstr "لم تنجح عملية الاستيراد"
#: cms/templates/import.html cms/templates/import.html
msgid "Choose new file"
msgstr "اختيار ملف جديد "
#: cms/templates/import.html
msgid "Your import is in progress; navigating away will abort it."
msgstr ""
"جاري عمل عملية الاستيراد؛ سيؤدّي تصفّح موقعٍ مختلف إلى إيقاف العملية. "
#: cms/templates/index.html cms/templates/index.html
#: cms/templates/widgets/header.html
msgid "My Courses"
@@ -16883,14 +17226,6 @@ msgstr ""
"ومتطلّبات الوقت التقريبية. ويستخدم الطلاب صفحات \"النبذة\" لاختيار المساقات "
"الجديدة التي سيلتحقون بها."
#: cms/templates/settings_advanced.html
msgid "Hide Deprecated Settings"
msgstr "إخفاء الإعدادات المهملة"
#: cms/templates/settings_advanced.html cms/templates/settings_advanced.html
msgid "Show Deprecated Settings"
msgstr "إظهار الإعدادات المهملة"
#: cms/templates/settings_advanced.html
msgid "Your policy changes have been saved."
msgstr "تم حفظ التغييرات على السياسة الخاصة بك."
@@ -16911,6 +17246,10 @@ msgstr ""
"<strong> تحذير </strong>: لا تقم بتعديل هذه السياسات إلا إذا كنت على علمٍ "
"بأغراضها."
#: cms/templates/settings_advanced.html
msgid "Show Deprecated Settings"
msgstr "إظهار الإعدادات المهملة"
#: cms/templates/settings_advanced.html
msgid "What do advanced settings do?"
msgstr "ما هي وظيفة الإعدادات المتقدّمة؟"
@@ -17022,10 +17361,6 @@ msgstr "تكبير أو تصغير"
msgid "This block contains multiple components."
msgstr "تحتوي هذه الخانة على عدة مكوِّنات "
#: cms/templates/textbooks.html
msgid "You have unsaved changes. Do you really want to leave this page?"
msgstr "ثّمة بعض التغييرات التي لم يتمّ حفظها. هل تريد حقّاً مغادرة الصفحة؟"
#: cms/templates/textbooks.html
msgid "New Textbook"
msgstr "كتاب جديد"

View File

@@ -67,7 +67,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2014-10-20 10:15-0400\n"
"POT-Creation-Date: 2014-10-27 10:49-0400\n"
"PO-Revision-Date: 2014-10-13 16:51+0000\n"
"Last-Translator: Nabeel El-Dughailib <nabeel@qordoba.com>\n"
"Language-Team: Arabic (http://www.transifex.com/projects/p/edx-platform/language/ar/)\n"
@@ -80,7 +80,8 @@ msgstr ""
#. Translators: this is a message from the raw HTML editor displayed in the
#. browser when a user needs to edit HTML
#: cms/static/coffee/src/views/tabs.js
#: cms/static/coffee/src/views/tabs.js cms/static/js/factories/manage_users.js
#: cms/static/js/factories/manage_users.js
#: cms/static/js/views/course_info_update.js
#: cms/static/js/views/modals/edit_xblock.js
#: common/lib/xmodule/xmodule/js/src/html/edit.js
@@ -91,7 +92,8 @@ msgstr "حسناً"
#. #-#-#-#-# djangojs-partial.po (edx-platform) #-#-#-#-#
#. Translators: this is a message from the raw HTML editor displayed in the
#. browser when a user needs to edit HTML
#: cms/static/coffee/src/views/tabs.js cms/static/js/views/asset.js
#: cms/static/coffee/src/views/tabs.js cms/static/js/factories/export.js
#: cms/static/js/factories/manage_users.js cms/static/js/views/asset.js
#: cms/static/js/views/course_info_update.js
#: cms/static/js/views/show_textbook.js cms/static/js/views/validation.js
#: cms/static/js/views/modals/base_modal.js
@@ -121,7 +123,14 @@ msgstr ""
"#-#-#-#-# underscore-studio.po (edx-platform) #-#-#-#-#\n"
"سيُفتح هذا الرابط في نافذة متصفّح/علامة تبويب جديدة"
#: cms/static/js/views/asset.js
#: cms/static/js/factories/manage_users.js
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
msgid "Unknown"
msgstr "غير معروف "
#: cms/static/js/factories/manage_users.js cms/static/js/views/asset.js
#: cms/static/js/views/group_configuration_item.js
#: cms/static/js/views/show_textbook.js
#: common/static/js/vendor/ova/catch/js/catch.js
@@ -2179,7 +2188,7 @@ msgstr[1] "انقر مربع الاختيار لإزالة إبلاغ %(واحد
msgstr[2] "انقر مربع الاختيار لإزالة إبلاغين %(اثنين)s"
msgstr[3] "انقر مربع الاختيار لإزالة %(بضعة)s إبلاغات"
msgstr[4] "انقر مربع الاختيار لإزالة %(عدة)s إبلاغات"
msgstr[5] "انقر مربع الاختيار لإزالة %(totalFlags)s إبلاغات"
msgstr[5] "انقر مربع الاختيار لإزالة %(count)s إبلاغات"
#: common/static/js/vendor/ova/flagging-annotator.js
msgid "All flags have been removed. To undo, uncheck the box."
@@ -2583,6 +2592,18 @@ msgstr ""
msgid "Error: You cannot remove yourself from the Instructor group!"
msgstr "حدث خطأ: لا يمكنك حذف نفسك من مجموعة موجّهي المساق!"
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "The following errors were generated:"
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "The following warnings were generated:"
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "All accounts were created successfully."
msgstr ""
#: lms/static/coffee/src/instructor_dashboard/membership.js
msgid "Error adding/removing users as beta testers."
msgstr ""
@@ -2973,12 +2994,6 @@ msgstr ""
"لقد وقع خطأ في استرداد بريدك الإلكتروني، يرجى المحاولة مجددًا لاحقًا. اتصل "
"بالدعم التقني في حال استمرار المشكلة."
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
#: lms/static/coffee/src/instructor_dashboard/util.js
msgid "Unknown"
msgstr "غير معروف "
#: lms/static/coffee/src/instructor_dashboard/util.js
msgid "Subject"
msgstr "الموضوع"
@@ -3252,6 +3267,16 @@ msgstr "يرجى إدخال بريد إلكتروني صالح"
msgid "Please enter a valid password"
msgstr "يرجى إدخال كلمة سر صالحة"
#: lms/static/js/student_account/account.js
msgid ""
"Password reset email sent. Follow the link in the email to change your "
"password."
msgstr ""
#: lms/static/js/student_account/account.js
msgid "We weren't able to send you a password reset email."
msgstr ""
#: lms/static/js/student_account/account.js
msgid "Please check your email to confirm the change"
msgstr "يرجى التحقق من بريدك الإلكتروني لتأكيد التغيير"
@@ -3455,6 +3480,139 @@ msgstr "المجموعة %s"
msgid "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
msgstr "أ ب ت ث ج ح خ د ذ ر ز س ش ص ض ط ظ ع غ ف ق ك ل م ن هـ و ي"
#: cms/static/js/factories/export.js
msgid "There has been an error while exporting."
msgstr ""
#: cms/static/js/factories/export.js
msgid ""
"There has been a failure to export to XML at least one component. It is "
"recommended that you go to the edit page and repair the error before "
"attempting another export. Please check that all components on the page are "
"valid and do not display any error messages."
msgstr ""
#: cms/static/js/factories/export.js
msgid "Correct failed component"
msgstr ""
#: cms/static/js/factories/export.js
msgid "Return to Export"
msgstr ""
#: cms/static/js/factories/export.js
msgid ""
"There has been a failure to export your course to XML. Unfortunately, we do "
"not have specific enough information to assist you in identifying the failed"
" component. It is recommended that you inspect your courseware to identify "
"any components in error and try again."
msgstr ""
#: cms/static/js/factories/export.js
msgid "The raw error message is:"
msgstr ""
#: cms/static/js/factories/export.js
msgid "There has been an error with your export."
msgstr ""
#: cms/static/js/factories/export.js
msgid "Yes, take me to the main course page"
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error during the upload process."
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error while unpacking the file."
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error while verifying the file you submitted."
msgstr ""
#: cms/static/js/factories/import.js
msgid "There was an error while importing the new course to our database."
msgstr ""
#: cms/static/js/factories/import.js
msgid "Your import has failed."
msgstr ""
#: cms/static/js/factories/import.js cms/static/js/factories/import.js
#: cms/static/js/views/import.js cms/static/js/views/import.js.c
msgid "Choose new file"
msgstr "اختر ملف جديد"
#: cms/static/js/factories/import.js
msgid "Your import is in progress; navigating away will abort it."
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "A valid email address is required"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "You must enter a valid email address in order to add a new team member"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Return and add email address"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Already a course team member"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid ""
"{email} is already on the “{course}” team. If you're trying to add a new "
"member, please double-check the email address you provided."
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Return to team listing"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Error adding user"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Are you sure?"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid ""
"Are you sure you want to delete {email} from the course team for “{course}”?"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Error removing user"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "There was an error changing the user's role"
msgstr ""
#: cms/static/js/factories/manage_users.js
msgid "Try Again"
msgstr ""
#: cms/static/js/factories/settings_advanced.js
msgid "Hide Deprecated Settings"
msgstr ""
#: cms/static/js/factories/settings_advanced.js
msgid "Show Deprecated Settings"
msgstr ""
#: cms/static/js/factories/textbooks.js
#: cms/static/js/views/pages/group_configurations.js
msgid "You have unsaved changes. Do you really want to leave this page?"
msgstr "هناك تغييرات غير محفوظة، هل تريد مغادرة الصفحة؟"
#: cms/static/js/models/course.js cms/static/js/models/section.js
msgid "You must specify a name"
msgstr "ينبغي أن تقوم بتحديد إسم"
@@ -3671,10 +3829,6 @@ msgstr "حذف إعدادات هذه المجموعة دائم ولا يمكن
msgid "Deleting"
msgstr "جاري الحذف"
#: cms/static/js/views/import.js cms/static/js/views/import.js.c
msgid "Choose new file"
msgstr "اختر ملف جديد"
#: cms/static/js/views/import.js
msgid "Error importing course"
msgstr "حدث خطأ أثناء استيراد المساق"
@@ -3924,10 +4078,6 @@ msgstr ""
msgid "Making Visible to Students&hellip;"
msgstr "تصبح مرئية للطلاب&hellip;"
#: cms/static/js/views/pages/group_configurations.js
msgid "You have unsaved changes. Do you really want to leave this page?"
msgstr "هناك تغييرات غير محفوظة، هل تريد مغادرة الصفحة؟"
#: cms/static/js/views/settings/advanced.js
msgid ""
"Your changes will not take effect until you save your progress. Take care "
@@ -4191,6 +4341,10 @@ msgstr "كلمة السر"
msgid "Change My Email Address"
msgstr "تغيير عنوان بريدي الإلكتروني"
#: lms/templates/student_account/account.underscore
msgid "Reset Password"
msgstr ""
#: lms/templates/student_profile/profile.underscore
msgid "Full Name"
msgstr "الإسم الكامل"

Some files were not shown because too many files have changed in this diff Show More